-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseNimiqLink.test.ts
More file actions
325 lines (279 loc) · 11.5 KB
/
Copy pathuseNimiqLink.test.ts
File metadata and controls
325 lines (279 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useNimiqLink } from '@/hooks/useNimiqLink'
import { NimiqProviderError, listNimiqAccounts, signWithNimiq } from '@/lib/nimiqProvider'
import { EthProviderError, personalSign, requestEthAccounts } from '@/lib/ethProvider'
import { isNimiqPay } from '@/lib/nimiq'
import { loadNimiqLink, saveNimiqLink, type NimiqLink } from '@/lib/nimiqLink'
vi.mock('@/lib/nimiq', () => ({ isNimiqPay: vi.fn(() => true) }))
vi.mock('@/lib/nimiqProvider', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/nimiqProvider')>()),
listNimiqAccounts: vi.fn(),
signWithNimiq: vi.fn(),
}))
vi.mock('@/lib/ethProvider', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/ethProvider')>()),
requestEthAccounts: vi.fn(),
personalSign: vi.fn(),
}))
const NIM = 'NQ07 0000 0000 0000 0000 0000 0000 0000 0001'
const BASE = '0x8db1EaAd99eF3a4c2AE4479D0570C00E12Be3f79'
function storedLink(overrides: Partial<NimiqLink> = {}): NimiqLink {
return {
nimAddress: NIM,
nimPublicKey: 'pk',
nimSignature: 'nimsig',
baseAddress: BASE.toLowerCase(),
baseSignature: '0xbasesig',
message: 'msg',
linkedAt: 1,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.mocked(isNimiqPay).mockReturnValue(true)
vi.mocked(listNimiqAccounts).mockResolvedValue([NIM])
vi.mocked(signWithNimiq).mockResolvedValue({ publicKey: 'pk', signature: 'nimsig' })
vi.mocked(personalSign).mockResolvedValue('0xbasesig')
vi.mocked(requestEthAccounts).mockResolvedValue([BASE])
})
/** Drive the whole flow; each `act` is one tap, as the UI enforces. */
async function runFullFlow(result: { current: ReturnType<typeof useNimiqLink> }) {
await act(async () => {
await result.current.requestAccount()
})
await act(async () => {
await result.current.signNim()
})
await act(async () => {
await result.current.signBase()
})
}
/**
* The rule under test throughout: each confirmed call across BOTH providers is
* reachable only from its own tap. A regression shows up here as a call that
* happened without an `act()` driving it.
*/
describe('dialog discipline', () => {
it('raises no dialog on mount', async () => {
renderHook(() => useNimiqLink(BASE))
await waitFor(() => expect(listNimiqAccounts).not.toHaveBeenCalled())
expect(signWithNimiq).not.toHaveBeenCalled()
expect(personalSign).not.toHaveBeenCalled()
expect(requestEthAccounts).not.toHaveBeenCalled()
})
it('does not chain the Nimiq signing dialog onto the account dialog', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.requestAccount()
})
expect(listNimiqAccounts).toHaveBeenCalledTimes(1)
expect(signWithNimiq).not.toHaveBeenCalled()
expect(result.current.status).toBe('account-ready')
})
it('does not chain the Base dialog onto the Nimiq signature', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.requestAccount()
})
await act(async () => {
await result.current.signNim()
})
expect(result.current.status).toBe('nim-signed')
expect(personalSign).not.toHaveBeenCalled()
})
// wagmi's injected connector already owns eth_requestAccounts, and the deed
// cannot reach this flow unconnected, so a second account dialog would buy
// nothing. The control below proves the flow did run.
it('never raises an eth_requestAccounts dialog of its own', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
expect(requestEthAccounts).not.toHaveBeenCalled()
expect(personalSign).toHaveBeenCalledTimes(1)
expect(result.current.proven).toBe(true)
})
it('restores a stored link without calling either provider', async () => {
saveNimiqLink(storedLink())
const { result } = renderHook(() => useNimiqLink(BASE))
await waitFor(() => expect(result.current.status).toBe('linked'))
expect(listNimiqAccounts).not.toHaveBeenCalled()
expect(personalSign).not.toHaveBeenCalled()
})
})
describe('the full dual-provider path', () => {
it('links after all three taps', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
expect(result.current.status).toBe('linked')
expect(result.current.proven).toBe(true)
expect(result.current.link).toMatchObject({
nimAddress: NIM,
nimSignature: 'nimsig',
baseAddress: BASE.toLowerCase(),
baseSignature: '0xbasesig',
})
})
// The binding is the pair over one message. Two signatures over two
// different challenges would prove two unrelated things.
it('signs the SAME challenge with both providers', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
const nimMessage = vi.mocked(signWithNimiq).mock.calls[0][0]
const baseMessage = vi.mocked(personalSign).mock.calls[0][0]
expect(baseMessage).toBe(nimMessage)
expect(result.current.link?.message).toBe(nimMessage)
})
it('signs a challenge naming both addresses', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
const signed = vi.mocked(signWithNimiq).mock.calls[0][0]
expect(signed).toContain(NIM)
expect(signed).toContain(BASE)
})
it('signs with the address the challenge names', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
expect(vi.mocked(personalSign).mock.calls[0][1]).toBe(BASE)
})
})
describe('the half-signed state', () => {
it('persists the NIM half but does not call it proven', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.requestAccount()
})
await act(async () => {
await result.current.signNim()
})
expect(result.current.proven).toBe(false)
expect(loadNimiqLink(BASE)?.baseSignature).toBeNull()
})
it('resumes at the Base step after a reload', async () => {
saveNimiqLink(storedLink({ baseSignature: null }))
const { result } = renderHook(() => useNimiqLink(BASE))
await waitFor(() => expect(result.current.status).toBe('nim-signed'))
expect(result.current.proven).toBe(false)
})
it('refuses the Base step before the Nimiq half exists', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.signBase()
})
expect(personalSign).not.toHaveBeenCalled()
expect(result.current.failedStep).toBe('nim-signature')
})
})
describe('declines and failures', () => {
it('reports a declined Nimiq account dialog and stays unlinked', async () => {
vi.mocked(listNimiqAccounts).mockRejectedValue(
new NimiqProviderError('User denied account access', 'USER_REJECTED'),
)
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.requestAccount()
})
expect(result.current.status).toBe('idle')
expect(result.current.error).toBe('User denied account access')
expect(result.current.failedStep).toBe('nim-account')
expect(loadNimiqLink(BASE)).toBeNull()
})
it('falls back to account-ready when the Nimiq signature is declined', async () => {
vi.mocked(signWithNimiq).mockRejectedValue(new NimiqProviderError('User cancelled'))
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.requestAccount()
})
await act(async () => {
await result.current.signNim()
})
expect(result.current.status).toBe('account-ready')
expect(result.current.failedStep).toBe('nim-signature')
expect(loadNimiqLink(BASE)).toBeNull()
})
// The NIM half cost the user two dialogs; a declined Base signature must not
// throw it away.
it('keeps the NIM half when the Base signature is declined', async () => {
vi.mocked(personalSign).mockRejectedValue(
new EthProviderError('User denied message signature.', 4001),
)
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
expect(result.current.status).toBe('nim-signed')
expect(result.current.failedStep).toBe('base-signature')
expect(result.current.proven).toBe(false)
expect(loadNimiqLink(BASE)?.nimSignature).toBe('nimsig')
expect(loadNimiqLink(BASE)?.baseSignature).toBeNull()
})
// Reachable when the wallet disconnects between the Nimiq half and the Base
// half. Signing with whatever the provider offers next would record a pair
// binding an address the holder never chose.
it('refuses to sign when the wallet disconnected mid-flow', async () => {
saveNimiqLink(storedLink({ baseSignature: null }))
const { result, rerender } = renderHook(
({ addr }: { addr: string | undefined }) => useNimiqLink(addr),
{ initialProps: { addr: BASE as string | undefined } },
)
await waitFor(() => expect(result.current.status).toBe('nim-signed'))
rerender({ addr: undefined })
await act(async () => {
await result.current.signBase()
})
expect(personalSign).not.toHaveBeenCalled()
})
it('refuses to start without a connected Base wallet', async () => {
const { result } = renderHook(() => useNimiqLink(undefined))
await act(async () => {
await result.current.requestAccount()
})
expect(listNimiqAccounts).not.toHaveBeenCalled()
expect(result.current.error).toBe('Connect a Base wallet first.')
})
it('refuses to sign with Nimiq before an account has been shared', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await act(async () => {
await result.current.signNim()
})
expect(signWithNimiq).not.toHaveBeenCalled()
expect(result.current.failedStep).toBe('nim-account')
})
})
describe('outside Nimiq Pay', () => {
it('reports unsupported and offers no flow', async () => {
vi.mocked(isNimiqPay).mockReturnValue(false)
const { result } = renderHook(() => useNimiqLink(BASE))
await waitFor(() => expect(result.current.status).toBe('unsupported'))
})
it('still shows a link made in a Nimiq Pay session', async () => {
saveNimiqLink(storedLink())
vi.mocked(isNimiqPay).mockReturnValue(false)
const { result } = renderHook(() => useNimiqLink(BASE))
await waitFor(() => expect(result.current.status).toBe('linked'))
expect(result.current.proven).toBe(true)
})
})
describe('switching wallets', () => {
it('drops the previous holder’s link when the Base address changes', async () => {
saveNimiqLink(storedLink())
const { result, rerender } = renderHook(
({ addr }: { addr: string }) => useNimiqLink(addr),
{ initialProps: { addr: BASE } },
)
await waitFor(() => expect(result.current.status).toBe('linked'))
rerender({ addr: '0x000000000000000000000000000000000000dead' })
await waitFor(() => expect(result.current.status).toBe('idle'))
expect(result.current.nimAddress).toBeNull()
expect(result.current.link).toBeNull()
expect(result.current.proven).toBe(false)
})
it('unlink forgets the record for this wallet only', async () => {
const { result } = renderHook(() => useNimiqLink(BASE))
await runFullFlow(result)
act(() => {
result.current.unlink()
})
expect(result.current.status).toBe('idle')
expect(loadNimiqLink(BASE)).toBeNull()
})
})