Skip to content

Commit 090214a

Browse files
committed
feat: enhance private key export with two-step MPC
education flow - Implement two-step UX: educational content first, then private key display - Add MPC vs MetaMask comparison dialog with proceed/cancel options - Integrate automatic clipboard copy functionality with error handling - Provide fallback manual copy option when clipboard API fails - Update comprehensive test suite covering all flow scenarios and edge cases
1 parent 86724ba commit 090214a

File tree

2 files changed

+362
-80
lines changed

2 files changed

+362
-80
lines changed
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
5+
// Override the global mock for this specific test file
6+
vi.mock('../exportPrivateKey', async () => {
7+
const actual = await vi.importActual('../exportPrivateKey');
8+
return actual;
9+
});
10+
11+
// Mock the hooks and dependencies
12+
vi.mock('@web3auth/modal/react', () => ({
13+
useWeb3Auth: vi.fn(),
14+
}));
15+
16+
vi.mock('wagmi', () => ({
17+
useChainId: vi.fn(),
18+
}));
19+
20+
// Import the actual component and mocked modules
21+
import { ExportPrivateKey } from '../exportPrivateKey';
22+
import { useWeb3Auth } from '@web3auth/modal/react';
23+
import { useChainId } from 'wagmi';
24+
25+
describe('ExportPrivateKey', () => {
26+
const mockWeb3Auth = {
27+
provider: {
28+
request: vi.fn(),
29+
},
30+
};
31+
32+
let alertSpy: ReturnType<typeof vi.spyOn>;
33+
let confirmSpy: ReturnType<typeof vi.spyOn>;
34+
35+
beforeEach(() => {
36+
// Mock window.alert and window.confirm
37+
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
38+
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
39+
40+
// Mock console.log to prevent noise in tests
41+
vi.spyOn(console, 'log').mockImplementation(() => {});
42+
vi.spyOn(console, 'error').mockImplementation(() => {});
43+
44+
// Setup default mocks
45+
(useWeb3Auth as ReturnType<typeof vi.fn>).mockReturnValue({
46+
web3Auth: mockWeb3Auth,
47+
});
48+
49+
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(1); // Ethereum Mainnet
50+
});
51+
52+
afterEach(() => {
53+
vi.clearAllMocks();
54+
});
55+
56+
it('renders component with export button', () => {
57+
render(<ExportPrivateKey />);
58+
59+
expect(screen.getByRole('heading', { name: 'Export Private Key' })).toBeInTheDocument();
60+
expect(screen.getByRole('button', { name: 'Export Private Key' })).toBeInTheDocument();
61+
expect(screen.getByText(/Network: Ethereum Mainnet/)).toBeInTheDocument();
62+
});
63+
64+
it('displays loading state when exporting', async () => {
65+
// Mock a delayed response
66+
mockWeb3Auth.provider.request.mockImplementation(
67+
() => new Promise(resolve => setTimeout(() => resolve('0x1234567890abcdef'), 100))
68+
);
69+
70+
render(<ExportPrivateKey />);
71+
72+
const button = screen.getByRole('button', { name: 'Export Private Key' });
73+
fireEvent.click(button);
74+
75+
expect(screen.getByRole('button', { name: 'Exporting...' })).toBeInTheDocument();
76+
expect(screen.getByRole('button', { name: 'Exporting...' })).toBeDisabled();
77+
78+
await waitFor(() => {
79+
expect(screen.getByRole('button', { name: 'Export Private Key' })).toBeInTheDocument();
80+
});
81+
});
82+
83+
it('displays educational content first, then private key on successful export', async () => {
84+
const mockPrivateKey = '0x1234567890abcdef';
85+
mockWeb3Auth.provider.request.mockResolvedValue(mockPrivateKey);
86+
87+
// Mock clipboard API
88+
Object.assign(navigator, {
89+
clipboard: {
90+
writeText: vi.fn().mockResolvedValue(undefined),
91+
},
92+
});
93+
94+
render(<ExportPrivateKey />);
95+
96+
const button = screen.getByRole('button', { name: 'Export Private Key' });
97+
fireEvent.click(button);
98+
99+
await waitFor(() => {
100+
expect(confirmSpy).toHaveBeenCalledTimes(2); // Educational message + private key confirmation
101+
});
102+
103+
// Verify first confirm shows educational content (without private key)
104+
const educationalMessage = confirmSpy.mock.calls[0][0];
105+
expect(educationalMessage).toContain('Web3Auth Advantage');
106+
expect(educationalMessage).toContain('MultiPartyComputation (MPC)');
107+
expect(educationalMessage).toContain('Unlike MetaMask');
108+
expect(educationalMessage).toContain('No MetaMask needed');
109+
expect(educationalMessage).toContain('https://web3auth.io/docs/features/mpc');
110+
expect(educationalMessage).toContain('Click OK to view your private key');
111+
expect(educationalMessage).not.toContain(mockPrivateKey); // Should NOT contain private key yet
112+
113+
// Verify second confirm shows private key
114+
const privateKeyMessage = confirmSpy.mock.calls[1][0];
115+
expect(privateKeyMessage).toContain(mockPrivateKey);
116+
expect(privateKeyMessage).toContain('Your Private Key');
117+
expect(privateKeyMessage).toContain('Click OK to copy to clipboard');
118+
119+
// Verify copy success alert
120+
expect(alertSpy).toHaveBeenCalledWith('✅ Private key copied to clipboard!');
121+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockPrivateKey);
122+
});
123+
124+
it('stops flow when user cancels educational step', async () => {
125+
const mockPrivateKey = '0x1234567890abcdef';
126+
mockWeb3Auth.provider.request.mockResolvedValue(mockPrivateKey);
127+
128+
// Mock user cancelling the educational step
129+
confirmSpy.mockReturnValue(false);
130+
131+
render(<ExportPrivateKey />);
132+
133+
const button = screen.getByRole('button', { name: 'Export Private Key' });
134+
fireEvent.click(button);
135+
136+
await waitFor(() => {
137+
expect(confirmSpy).toHaveBeenCalledTimes(1); // Only educational message
138+
});
139+
140+
// Should not show private key or copy functionality
141+
expect(confirmSpy).toHaveBeenCalledTimes(1);
142+
expect(alertSpy).not.toHaveBeenCalled();
143+
});
144+
145+
it('stops flow when user cancels private key copy step', async () => {
146+
const mockPrivateKey = '0x1234567890abcdef';
147+
mockWeb3Auth.provider.request.mockResolvedValue(mockPrivateKey);
148+
149+
// Mock user confirming educational step but cancelling copy step
150+
confirmSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
151+
152+
render(<ExportPrivateKey />);
153+
154+
const button = screen.getByRole('button', { name: 'Export Private Key' });
155+
fireEvent.click(button);
156+
157+
await waitFor(() => {
158+
expect(confirmSpy).toHaveBeenCalledTimes(2); // Educational + private key confirmation
159+
});
160+
161+
// Should not copy to clipboard
162+
expect(alertSpy).not.toHaveBeenCalled();
163+
});
164+
165+
it('handles clipboard copy failure gracefully', async () => {
166+
const mockPrivateKey = '0x1234567890abcdef';
167+
mockWeb3Auth.provider.request.mockResolvedValue(mockPrivateKey);
168+
169+
// Mock clipboard API failure
170+
Object.assign(navigator, {
171+
clipboard: {
172+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard access denied')),
173+
},
174+
});
175+
176+
render(<ExportPrivateKey />);
177+
178+
const button = screen.getByRole('button', { name: 'Export Private Key' });
179+
fireEvent.click(button);
180+
181+
await waitFor(() => {
182+
expect(confirmSpy).toHaveBeenCalledTimes(2);
183+
});
184+
185+
// Should show fallback message with private key for manual copy
186+
expect(alertSpy).toHaveBeenCalledWith(
187+
expect.stringContaining('❌ Failed to copy automatically. Please copy manually:')
188+
);
189+
expect(alertSpy).toHaveBeenCalledWith(
190+
expect.stringContaining(mockPrivateKey)
191+
);
192+
});
193+
194+
it('uses correct private key method for Ethereum mainnet', async () => {
195+
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(1);
196+
mockWeb3Auth.provider.request.mockResolvedValue('0x1234567890abcdef');
197+
198+
render(<ExportPrivateKey />);
199+
200+
const button = screen.getByRole('button', { name: 'Export Private Key' });
201+
fireEvent.click(button);
202+
203+
await waitFor(() => {
204+
expect(mockWeb3Auth.provider.request).toHaveBeenCalledWith({
205+
method: 'eth_private_key'
206+
});
207+
});
208+
});
209+
210+
it('uses correct private key method for Polkadot-based networks', async () => {
211+
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(420420422); // Passet Hub
212+
mockWeb3Auth.provider.request.mockResolvedValue('0x1234567890abcdef');
213+
214+
render(<ExportPrivateKey />);
215+
216+
const button = screen.getByRole('button', { name: 'Export Private Key' });
217+
fireEvent.click(button);
218+
219+
await waitFor(() => {
220+
expect(mockWeb3Auth.provider.request).toHaveBeenCalledWith({
221+
method: 'private_key'
222+
});
223+
});
224+
});
225+
226+
it('displays network information correctly', () => {
227+
// Test Kusama Asset Hub
228+
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(420420418);
229+
const { rerender } = render(<ExportPrivateKey />);
230+
expect(screen.getByText(/Network: Kusama Asset Hub/)).toBeInTheDocument();
231+
expect(screen.getByText(/Type: Polkadot-based/)).toBeInTheDocument();
232+
233+
// Test Westend
234+
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(420420421);
235+
rerender(<ExportPrivateKey />);
236+
expect(screen.getByText(/Network: Westend Network/)).toBeInTheDocument();
237+
238+
// Test Ethereum
239+
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(1);
240+
rerender(<ExportPrivateKey />);
241+
expect(screen.getByText(/Network: Ethereum Mainnet/)).toBeInTheDocument();
242+
expect(screen.getByText(/Type: EVM/)).toBeInTheDocument();
243+
});
244+
245+
it('disables button when Web3Auth provider is not available', async () => {
246+
(useWeb3Auth as ReturnType<typeof vi.fn>).mockReturnValue({
247+
web3Auth: { provider: null },
248+
});
249+
250+
render(<ExportPrivateKey />);
251+
252+
const button = screen.getByRole('button', { name: 'Export Private Key' });
253+
expect(button).toBeDisabled();
254+
255+
// Verify no alert is triggered on disabled button
256+
fireEvent.click(button);
257+
expect(alertSpy).not.toHaveBeenCalled();
258+
});
259+
260+
it('handles private key retrieval failure', async () => {
261+
mockWeb3Auth.provider.request.mockResolvedValue(null);
262+
263+
render(<ExportPrivateKey />);
264+
265+
const button = screen.getByRole('button', { name: 'Export Private Key' });
266+
fireEvent.click(button);
267+
268+
await waitFor(() => {
269+
expect(screen.getByText(/Failed to retrieve private key/)).toBeInTheDocument();
270+
});
271+
272+
expect(alertSpy).not.toHaveBeenCalled();
273+
});
274+
275+
it('handles Web3Auth provider errors gracefully', async () => {
276+
const errorMessage = 'Network error';
277+
mockWeb3Auth.provider.request.mockRejectedValue(new Error(errorMessage));
278+
279+
render(<ExportPrivateKey />);
280+
281+
const button = screen.getByRole('button', { name: 'Export Private Key' });
282+
fireEvent.click(button);
283+
284+
await waitFor(() => {
285+
expect(screen.getByText(new RegExp(errorMessage))).toBeInTheDocument();
286+
});
287+
288+
expect(alertSpy).not.toHaveBeenCalled();
289+
});
290+
291+
it('displays security warning message', () => {
292+
render(<ExportPrivateKey />);
293+
294+
expect(screen.getByText(/Never share your private key with anyone/)).toBeInTheDocument();
295+
expect(screen.getByText(/Store it securely/)).toBeInTheDocument();
296+
});
297+
298+
it('educational and private key messages are mobile-friendly', async () => {
299+
const mockPrivateKey = '0x1234567890abcdef';
300+
mockWeb3Auth.provider.request.mockResolvedValue(mockPrivateKey);
301+
302+
// Mock clipboard API
303+
Object.assign(navigator, {
304+
clipboard: {
305+
writeText: vi.fn().mockResolvedValue(undefined),
306+
},
307+
});
308+
309+
render(<ExportPrivateKey />);
310+
311+
const button = screen.getByRole('button', { name: 'Export Private Key' });
312+
fireEvent.click(button);
313+
314+
await waitFor(() => {
315+
expect(confirmSpy).toHaveBeenCalledTimes(2);
316+
});
317+
318+
// Verify educational message format
319+
const educationalMessage = confirmSpy.mock.calls[0][0];
320+
expect(educationalMessage).toMatch(/💡 Web3Auth Advantage:.+No MetaMask needed \n\nLearn more about MPC:/s);
321+
expect(educationalMessage).toContain('Click OK to view your private key');
322+
323+
// Verify private key message format
324+
const privateKeyMessage = confirmSpy.mock.calls[1][0];
325+
expect(privateKeyMessage).toMatch(/🔐 Your Private Key:\n\n.+\n\nClick OK to copy to clipboard/s);
326+
expect(privateKeyMessage).toContain(mockPrivateKey);
327+
});
328+
});

0 commit comments

Comments
 (0)