Skip to content

Commit 6f2c6e7

Browse files
committed
test(wallet): add tests for wallet auto-reconnect and document security (#222)
1 parent e85e19b commit 6f2c6e7

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Wallet Security and Persistence
2+
3+
## Storing Public Keys vs Private Keys
4+
In the interest of user experience, our application persists the wallet session using `localStorage` to attempt silent auto-reconnects on page reloads.
5+
6+
**Important Security Implications:**
7+
1. **Never Store Secrets:** We only persist the `walletType` and the `publicKey` (Stellar Address) in localStorage.
8+
2. **Never** store seed phrases, private keys, or signed transactions in local storage or sessionStorage, as these are vulnerable to XSS (Cross-Site Scripting) attacks.
9+
3. **Public Nature of Addresses:** A public key is not secret. Storing it in localStorage merely indicates which account the user was last trying to connect with.
10+
4. **Validation on Reconnect:** During silent reconnect (`app mount`), the app queries the wallet extension or kit for the active address. We explicitly validate that the returned address matches the persisted public key. If there is a mismatch (e.g., the user switched accounts in their wallet extension), the session is cleared to prevent impersonation bugs.
11+
12+
By enforcing these constraints, we ensure a smooth UX without compromising the user's private keys or allowing incorrect application states.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React from 'react';
2+
import { render, act, waitFor } from '@testing-library/react';
3+
import { StellarWalletsKit } from '@creit.tech/stellar-wallets-kit';
4+
import { WalletProvider, useWalletContext } from '../context/WalletContext';
5+
import { toast } from '@/components/ui/use-toast';
6+
7+
// Mock dependencies
8+
jest.mock('@creit.tech/stellar-wallets-kit', () => ({
9+
StellarWalletsKit: {
10+
init: jest.fn(),
11+
on: jest.fn(),
12+
setWallet: jest.fn(),
13+
getAddress: jest.fn(),
14+
getNetwork: jest.fn(),
15+
setNetwork: jest.fn(),
16+
disconnect: jest.fn(),
17+
signTransaction: jest.fn(),
18+
},
19+
Networks: { PUBLIC: 'public', TESTNET: 'testnet', FUTURENET: 'futurenet' },
20+
KitEventType: { STATE_UPDATED: 'state_updated', DISCONNECT: 'disconnect' },
21+
}));
22+
23+
jest.mock('@creit.tech/stellar-wallets-kit/modules/freighter', () => ({
24+
FreighterModule: jest.fn(),
25+
FREIGHTER_ID: 'freighter',
26+
}));
27+
28+
jest.mock('@creit.tech/stellar-wallets-kit/modules/xbull', () => ({
29+
xBullModule: jest.fn(),
30+
XBULL_ID: 'xbull',
31+
}));
32+
33+
jest.mock('@/components/ui/use-toast', () => ({
34+
toast: jest.fn(),
35+
}));
36+
37+
const LS_WALLET_SESSION = 'niffyinsur-wallet-session-v1';
38+
39+
const TestComponent = () => {
40+
const { address, activeWalletId, connectionStatus } = useWalletContext();
41+
return (
42+
<div>
43+
<div data-testid="address">{address || 'none'}</div>
44+
<div data-testid="wallet-id">{activeWalletId || 'none'}</div>
45+
<div data-testid="status">{connectionStatus}</div>
46+
</div>
47+
);
48+
};
49+
50+
describe('WalletContext Auto-Reconnect', () => {
51+
beforeEach(() => {
52+
localStorage.clear();
53+
jest.clearAllMocks();
54+
(StellarWalletsKit.getNetwork as jest.Mock).mockResolvedValue({ network: 'testnet' });
55+
});
56+
57+
it('silently reconnects when valid session exists', async () => {
58+
const session = { walletId: 'freighter', publicKey: 'GBXYZ...' };
59+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify(session));
60+
61+
(StellarWalletsKit.getAddress as jest.Mock).mockResolvedValue({ address: 'GBXYZ...' });
62+
63+
const { getByTestId } = render(
64+
<WalletProvider>
65+
<TestComponent />
66+
</WalletProvider>
67+
);
68+
69+
await waitFor(() => {
70+
expect(getByTestId('status').textContent).toBe('connected');
71+
expect(getByTestId('address').textContent).toBe('GBXYZ...');
72+
expect(getByTestId('wallet-id').textContent).toBe('freighter');
73+
});
74+
75+
expect(StellarWalletsKit.setWallet).toHaveBeenCalledWith('freighter');
76+
});
77+
78+
it('clears session when public keys mismatch', async () => {
79+
// Session is for GBXYZ, but adapter returns GB123
80+
const session = { walletId: 'xbull', publicKey: 'GBXYZ...' };
81+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify(session));
82+
83+
(StellarWalletsKit.getAddress as jest.Mock).mockResolvedValue({ address: 'GB123...' });
84+
85+
const { getByTestId } = render(
86+
<WalletProvider>
87+
<TestComponent />
88+
</WalletProvider>
89+
);
90+
91+
await waitFor(() => {
92+
expect(localStorage.getItem(LS_WALLET_SESSION)).toBeNull();
93+
expect(getByTestId('status').textContent).toBe('disconnected');
94+
});
95+
});
96+
97+
it('shows non-blocking banner (toast) when reconnect fails', async () => {
98+
const session = { walletId: 'freighter', publicKey: 'GBXYZ...' };
99+
localStorage.setItem(LS_WALLET_SESSION, JSON.stringify(session));
100+
101+
(StellarWalletsKit.getAddress as jest.Mock).mockRejectedValue(new Error('Locked'));
102+
103+
render(
104+
<WalletProvider>
105+
<TestComponent />
106+
</WalletProvider>
107+
);
108+
109+
await waitFor(() => {
110+
expect(toast).toHaveBeenCalledWith(expect.objectContaining({
111+
title: 'Reconnect failed',
112+
variant: 'default',
113+
}));
114+
});
115+
});
116+
});

0 commit comments

Comments
 (0)