Skip to content

Commit 4e29616

Browse files
authored
Merge pull request #473 from mCodex/feat/testing
test: add Jest config, unit tests, and CI workflow
2 parents 5b44d40 + 36bf7ca commit 4e29616

25 files changed

Lines changed: 2955 additions & 38 deletions

.github/workflows/test.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Test
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
push:
8+
branches:
9+
- main
10+
paths:
11+
- '.github/workflows/test.yml'
12+
- 'src/**'
13+
- 'package.json'
14+
- 'yarn.lock'
15+
- 'tsconfig*.json'
16+
- 'jest.config.js'
17+
- 'jest.setup.ts'
18+
pull_request:
19+
paths:
20+
- '.github/workflows/test.yml'
21+
- 'src/**'
22+
- 'package.json'
23+
- 'yarn.lock'
24+
- 'tsconfig*.json'
25+
- 'jest.config.js'
26+
- 'jest.setup.ts'
27+
workflow_dispatch:
28+
29+
jobs:
30+
unit-tests:
31+
name: Run unit tests
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
- uses: actions/setup-node@v4
37+
with:
38+
node-version: '22'
39+
cache: yarn
40+
41+
- name: Install dependencies
42+
run: yarn install --frozen-lockfile
43+
44+
- name: Run Jest with coverage
45+
run: yarn test:coverage

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,8 @@ android/keystores/debug.keystore
7575
# generated by bob
7676
lib/
7777
tsconfig.tsbuildinfo
78-
nitrogen/
78+
nitrogen/
79+
80+
81+
# Testing
82+
coverage/

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
[![npm version](https://img.shields.io/npm/v/react-native-sensitive-info)](https://www.npmjs.com/package/react-native-sensitive-info)
44
[![npm downloads](https://img.shields.io/npm/dm/react-native-sensitive-info)](https://www.npmjs.com/package/react-native-sensitive-info)
5+
[![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](https://github.com/mcodex/react-native-sensitive-info)
56
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
67

78
Modern secure storage for React Native, powered by Nitro Modules. Version 6 ships a new headless API surface, stronger security defaults, and a fully revamped example app.
@@ -53,6 +54,7 @@ Modern secure storage for React Native, powered by Nitro Modules. Version 6 ship
5354

5455
| Platform | Minimum OS | Notes |
5556
| --- | --- | --- |
57+
| React Native | 0.76.0 | Requires `react-native-nitro-modules` for Nitro hybrid core. |
5658
| iOS | 13.0 | Requires Face ID usage string when biometrics are enabled. |
5759
| Android | API 23 (Marshmallow) | StrongBox detection requires API 28+; biometrics fall back to device credential when unavailable. |
5860
| Windows || Removed in v6. Earlier versions may still work but are no longer maintained. |

jest.config.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/** @type {import('jest').Config} */
2+
const config = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'jsdom',
5+
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
6+
transform: {
7+
'^.+\\.(ts|tsx)$': [
8+
'ts-jest',
9+
{
10+
tsconfig: '<rootDir>/tsconfig.test.json',
11+
isolatedModules: false,
12+
},
13+
],
14+
},
15+
moduleNameMapper: {
16+
'^react-native$': '<rootDir>/src/__tests__/__mocks__/react-native.ts',
17+
},
18+
collectCoverage: true,
19+
collectCoverageFrom: [
20+
'src/**/*.{ts,tsx}',
21+
'!src/**/__tests__/**',
22+
'!src/**/*.nitro.ts',
23+
],
24+
coverageThreshold: {
25+
global: {
26+
statements: 95,
27+
branches: 90,
28+
functions: 90,
29+
lines: 95,
30+
},
31+
},
32+
testMatch: ['<rootDir>/src/**/?(*.)+(spec|test).ts?(x)'],
33+
}
34+
35+
module.exports = config

package.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"clean": "git clean -dfX",
1313
"release": "semantic-release",
1414
"build": "npm run typecheck && bob build",
15-
"codegen": "nitrogen --logLevel=\"debug\" && npm run build && node post-script.js"
15+
"codegen": "nitrogen --logLevel=\"debug\" && npm run build && node post-script.js",
16+
"test": "jest",
17+
"test:coverage": "jest --coverage"
1618
},
1719
"keywords": [
1820
"react-native",
@@ -57,7 +59,9 @@
5759
"@jamesacarr/eslint-formatter-github-actions": "^0.2.0",
5860
"@semantic-release/changelog": "^6.0.3",
5961
"@semantic-release/git": "^10.0.1",
60-
"@types/jest": "^30.0.0",
62+
"@testing-library/dom": "^10.4.1",
63+
"@testing-library/react": "^16.0.0",
64+
"@types/jest": "^29.5.12",
6165
"@types/react": "19.2.x",
6266
"babel-plugin-react-compiler": "^1.0.0",
6367
"conventional-changelog-conventionalcommits": "^9.1.0",
@@ -72,14 +76,19 @@
7276
"eslint-plugin-react": "^7.37.5",
7377
"eslint-plugin-react-hooks": "^7.0.1",
7478
"globals": "^16.4.0",
79+
"jest": "^29.7.0",
80+
"jest-environment-jsdom": "^29.7.0",
7581
"jiti": "^2.6.1",
7682
"nitrogen": "0.31.2",
7783
"prettier": "^3.6.2",
7884
"react": "19.1.1",
85+
"react-dom": "19.1.1",
7986
"react-native": "0.82",
8087
"react-native-builder-bob": "^0.40.13",
8188
"react-native-nitro-modules": "0.31.2",
8289
"semantic-release": "^25.0.1",
90+
"ts-jest": "^29.2.5",
91+
"ts-node": "^10.9.2",
8392
"typescript": "^5.9.3",
8493
"typescript-eslint": "^8.46.2"
8594
},
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export class MockHybridObject {
2+
static instances: MockHybridObject[] = []
3+
4+
constructor() {
5+
MockHybridObject.instances.push(this)
6+
}
7+
}
8+
9+
export const getHybridObjectConstructor = jest
10+
.fn(() => MockHybridObject)
11+
.mockName('getHybridObjectConstructor')
12+
13+
export const __resetMocks = () => {
14+
MockHybridObject.instances = []
15+
getHybridObjectConstructor.mockReset()
16+
getHybridObjectConstructor.mockReturnValue(MockHybridObject)
17+
}
18+
19+
__resetMocks()
20+
21+
export default {
22+
getHybridObjectConstructor,
23+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const NativeModules = {}
2+
3+
export default {
4+
NativeModules,
5+
}

src/__tests__/core.storage.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import type {
2+
SensitiveInfoDeleteRequest,
3+
SensitiveInfoEnumerateRequest,
4+
SensitiveInfoGetRequest,
5+
SensitiveInfoHasRequest,
6+
SensitiveInfoOptions,
7+
SensitiveInfoSetRequest,
8+
} from '../sensitive-info.nitro'
9+
10+
describe('core/storage', () => {
11+
const nativeHandle = {
12+
setItem: jest.fn(),
13+
getItem: jest.fn(),
14+
hasItem: jest.fn(),
15+
deleteItem: jest.fn(),
16+
getAllItems: jest.fn(),
17+
clearService: jest.fn(),
18+
getSupportedSecurityLevels: jest.fn(),
19+
}
20+
21+
const normalizeOptions = jest
22+
.fn<
23+
ReturnType<typeof import('../internal/options').normalizeOptions>,
24+
[SensitiveInfoOptions | undefined]
25+
>()
26+
.mockReturnValue({
27+
service: 'normalized',
28+
accessControl: 'secureEnclaveBiometry',
29+
})
30+
31+
const isNotFoundError = jest.fn()
32+
33+
const loadModule = async () => {
34+
jest.resetModules()
35+
36+
jest.doMock('../internal/native', () => ({
37+
__esModule: true,
38+
default: jest.fn(() => nativeHandle),
39+
}))
40+
41+
jest.doMock('../internal/options', () => ({
42+
normalizeOptions,
43+
}))
44+
45+
jest.doMock('../internal/errors', () => ({
46+
isNotFoundError,
47+
}))
48+
49+
return import('../core/storage')
50+
}
51+
52+
beforeEach(() => {
53+
jest.clearAllMocks()
54+
Object.values(nativeHandle).forEach((value) => {
55+
if (typeof value === 'function') {
56+
value.mockReset()
57+
}
58+
})
59+
normalizeOptions.mockClear()
60+
normalizeOptions.mockReturnValue({
61+
service: 'normalized',
62+
accessControl: 'secureEnclaveBiometry',
63+
})
64+
isNotFoundError.mockReset()
65+
})
66+
67+
it('delegates setItem to the native layer', async () => {
68+
const { setItem } = await loadModule()
69+
70+
nativeHandle.setItem.mockResolvedValue({ metadata: {} })
71+
72+
await setItem('token', 'secret', { service: 'service' })
73+
74+
expect(normalizeOptions).toHaveBeenCalledWith({ service: 'service' })
75+
expect(nativeHandle.setItem).toHaveBeenCalledWith({
76+
key: 'token',
77+
value: 'secret',
78+
service: 'normalized',
79+
accessControl: 'secureEnclaveBiometry',
80+
} as SensitiveInfoSetRequest)
81+
})
82+
83+
it('returns null when a key is missing', async () => {
84+
const { getItem } = await loadModule()
85+
86+
const error = new Error('Missing [E_NOT_FOUND] key')
87+
nativeHandle.getItem.mockRejectedValueOnce(error)
88+
isNotFoundError.mockReturnValueOnce(true)
89+
90+
const result = await getItem('token', { service: 'service' })
91+
92+
expect(result).toBeNull()
93+
expect(normalizeOptions).toHaveBeenCalled()
94+
})
95+
96+
it('rethrows unexpected errors during getItem', async () => {
97+
const { getItem } = await loadModule()
98+
99+
const error = new Error('Boom')
100+
nativeHandle.getItem.mockRejectedValueOnce(error)
101+
isNotFoundError.mockReturnValueOnce(false)
102+
103+
await expect(getItem('token')).rejects.toBe(error)
104+
})
105+
106+
it('passes includeValue defaults to getItem', async () => {
107+
const { getItem } = await loadModule()
108+
109+
nativeHandle.getItem.mockResolvedValueOnce({ key: 'token' })
110+
111+
await getItem('token')
112+
113+
expect(nativeHandle.getItem).toHaveBeenCalledWith({
114+
key: 'token',
115+
includeValue: true,
116+
service: 'normalized',
117+
accessControl: 'secureEnclaveBiometry',
118+
} as SensitiveInfoGetRequest)
119+
})
120+
121+
it('delegates hasItem to the native layer', async () => {
122+
const { hasItem } = await loadModule()
123+
124+
nativeHandle.hasItem.mockResolvedValueOnce(true)
125+
126+
const result = await hasItem('token', { service: 'service' })
127+
128+
expect(result).toBe(true)
129+
expect(nativeHandle.hasItem).toHaveBeenCalledWith({
130+
key: 'token',
131+
service: 'normalized',
132+
accessControl: 'secureEnclaveBiometry',
133+
} as SensitiveInfoHasRequest)
134+
})
135+
136+
it('delegates deleteItem to the native layer', async () => {
137+
const { deleteItem } = await loadModule()
138+
139+
nativeHandle.deleteItem.mockResolvedValueOnce(true)
140+
141+
const result = await deleteItem('token', { service: 'service' })
142+
143+
expect(result).toBe(true)
144+
expect(nativeHandle.deleteItem).toHaveBeenCalledWith({
145+
key: 'token',
146+
service: 'normalized',
147+
accessControl: 'secureEnclaveBiometry',
148+
} as SensitiveInfoDeleteRequest)
149+
})
150+
151+
it('returns entries using getAllItems with includeValues default', async () => {
152+
const { getAllItems } = await loadModule()
153+
154+
nativeHandle.getAllItems.mockResolvedValueOnce([])
155+
156+
await getAllItems({ includeValues: true })
157+
158+
expect(nativeHandle.getAllItems).toHaveBeenCalledWith({
159+
includeValues: true,
160+
service: 'normalized',
161+
accessControl: 'secureEnclaveBiometry',
162+
} as SensitiveInfoEnumerateRequest)
163+
})
164+
165+
it('clears a service via native call', async () => {
166+
const { clearService } = await loadModule()
167+
168+
nativeHandle.clearService.mockResolvedValueOnce(undefined)
169+
170+
await clearService({ service: 'auth' })
171+
172+
expect(nativeHandle.clearService).toHaveBeenCalledWith({
173+
service: 'normalized',
174+
accessControl: 'secureEnclaveBiometry',
175+
})
176+
})
177+
178+
it('forwards getSupportedSecurityLevels', async () => {
179+
const { getSupportedSecurityLevels } = await loadModule()
180+
181+
nativeHandle.getSupportedSecurityLevels.mockResolvedValueOnce({
182+
secureEnclave: true,
183+
strongBox: true,
184+
biometry: true,
185+
deviceCredential: false,
186+
})
187+
188+
const result = await getSupportedSecurityLevels()
189+
190+
expect(result).toEqual({
191+
secureEnclave: true,
192+
strongBox: true,
193+
biometry: true,
194+
deviceCredential: false,
195+
})
196+
expect(nativeHandle.getSupportedSecurityLevels).toHaveBeenCalled()
197+
})
198+
199+
it('exposes a namespace mirroring the helpers', async () => {
200+
const module = await loadModule()
201+
202+
expect(module.SensitiveInfo.setItem).toBe(module.setItem)
203+
expect(module.SensitiveInfo.getItem).toBe(module.getItem)
204+
expect(module.SensitiveInfo.clearService).toBe(module.clearService)
205+
})
206+
})

0 commit comments

Comments
 (0)