feat: multi-wallet architecture with localStorage state, EIP-712 verification, cross-tab WS leadership
- wallets.js: WalletManager state management with chain validation - verifier.js: WalletVerifier EIP-712 signing via window.ethereum - stream.js: WalletStreamManager with BroadcastChannel leader election, backoff + jitter - AGENTS.md: updated with coding guidelines
This commit is contained in:
116
test/README.md
Normal file
116
test/README.md
Normal file
@ -0,0 +1,116 @@
|
||||
# cbBTC Dashboard Test Suite
|
||||
|
||||
The test suite is organized under the `/test` folder. All tests run in Node.js with ES modules and mock browser APIs (`localStorage`, `WebSocket`, `BroadcastChannel`, `window.ethereum`). The setup file `test/test_setup.mjs` provides the global `location` object that `stream.js` requires at import time.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Targets | Requires | How to run |
|
||||
|---|---|---|---|
|
||||
| `test/test_wallets.js` | **WalletManager** — CRUD, persistence, security | None | `node --experimental-modules test/test_wallets.js` |
|
||||
| `test/test_stream.js` | **WalletStreamManager** — WebSocket, backoff, routing | `test/test_setup.mjs` | `node --experimental-modules --import ./test/test_setup.mjs ./test/test_stream.js` |
|
||||
| `test/test_tabs.js` | **WalletStreamManager** — multi-tab leader election, failover | `test/test_setup.mjs` | `node --experimental-modules --import ./test/test_setup.mjs ./test/test_tabs.js` |
|
||||
| `test/test_verifier.js` | **WalletVerifier** — Web3 provider error handling | None | `node --experimental-modules test/test_verifier.js` |
|
||||
| `test/test_setup.mjs` | Provides `globalThis.location` for `stream.js` | — | Imported via `--import` |
|
||||
|
||||
## What Can Be Tested — by Module
|
||||
|
||||
### 1. WalletManager (`test_wallets.js`)
|
||||
|
||||
The `WalletManager` class manages tracked wallets in `localStorage`. The test suite covers:
|
||||
|
||||
**Address Validation**
|
||||
- Rejection of invalid address formats per chain (EVM, Bitcoin, Solana).
|
||||
- Acceptance of valid addresses for EVM (`0x...`), Bitcoin legacy (`1...`), and Solana base58 patterns.
|
||||
|
||||
**CRUD (Create/Read/Update/Delete)**
|
||||
- `addWallet()` — adds wallets, persists to storage, returns success/error.
|
||||
- `getWallets()` — returns deep copies of tracked wallets.
|
||||
- `findWallet()` — returns wallet object or `undefined`.
|
||||
- `removeWallet()` — splices wallet from state and persists.
|
||||
|
||||
**Duplicate Guard**
|
||||
- Same `address` + `chain` pair is rejected on second `addWallet()` call.
|
||||
|
||||
**Verification Lifecycle**
|
||||
- Wallets start with `isVerified: false`.
|
||||
- `verifyWallet()` sets the flag to `true` and stores the signature.
|
||||
- `revokeVerification()` resets the flag and clears the signature.
|
||||
|
||||
**Security: localStorage Tampering**
|
||||
- `loadWallets()` always resets `isVerified` to `false` on restart, regardless of what was written to storage. This prevents a user from forging verification by editing `localStorage` directly.
|
||||
|
||||
### 2. WalletVerifier (`test_verifier.js`)
|
||||
|
||||
The `WalletVerifier` class handles Web3 provider interactions. The test suite covers error-path cases:
|
||||
|
||||
**Missing Extension**
|
||||
- When `window.ethereum` is undefined, verification fails with `"No Web3 wallet extension found"`.
|
||||
|
||||
**User Rejection (Account Request)**
|
||||
- When the user denies `eth_requestAccounts` (EIP-1193 error 4001), verification cancels with `"Verification cancelled"`.
|
||||
|
||||
**Signature Rejection**
|
||||
- When the user denies the typed-data signature request, the error is mapped to `"rejected"`.
|
||||
|
||||
### 3. WalletStreamManager — Core (`test_stream.js`)
|
||||
|
||||
The `WalletStreamManager` class manages the WebSocket connection, event routing, and reconnect logic with exponential backoff. The test suite covers:
|
||||
|
||||
**Multiplexed Subscription**
|
||||
- On connect, the manager sends a single `subscribe` frame containing all tracked wallets (from `WalletManager`).
|
||||
|
||||
**Event Routing**
|
||||
- Incoming server messages are parsed and dispatched to registered `on('balance_update', ...)` callbacks.
|
||||
- Events route by `data.type` (or `data.event`, or fallback `'message'`).
|
||||
|
||||
**Callback Error Isolation**
|
||||
- If one registered callback throws, remaining callbacks for the same event still execute. Exceptions are caught and logged but never propagate.
|
||||
|
||||
**Dynamic Subscription**
|
||||
- `subscribeToNewWallet()` sends an immediate `subscribe_wallet` frame without cycling the connection.
|
||||
|
||||
**Exponential Backoff + Jitter**
|
||||
- On network drop (`simulateNetworkDrop()`), the manager retries connecting. The delay follows `BASE_DELAY * 2^attempt` with ±25% jitter, capped at `MAX_DELAY` (30s).
|
||||
- Tests speed up time by overriding `setTimeout` to fire instantly.
|
||||
|
||||
### 4. WalletStreamManager — Multi-Tab (`test_tabs.js`)
|
||||
|
||||
The leader/follower election and cross-tab synchronization system relies on `BroadcastChannel` and `localStorage` heartbeats. The test suite covers:
|
||||
|
||||
**Leader Election**
|
||||
- First tab to connect finds no heartbeat in `localStorage`, claims leadership, opens the WebSocket, and starts heartbeats.
|
||||
- Second tab finds the heartbeat, assigns itself as a follower, and avoids opening a second WebSocket.
|
||||
|
||||
**Cross-Tab Event Broadcasting**
|
||||
- Leader receives server data on the WebSocket and posts it to `BroadcastChannel`.
|
||||
- Followers receive the broadcast and dispatch it to their local listeners.
|
||||
|
||||
**Proxy Subscription Forwarding**
|
||||
- When a follower calls `subscribeToNewWallet()`, the request is sent through `BroadcastChannel` to the leader, which opens the actual network subscription.
|
||||
|
||||
**Automatic Failover**
|
||||
- When the leader tab closes (`disconnect()`), it broadcasts `LEADER_DEATH` before closing the channel.
|
||||
- Followers detect the signal, claim leadership, remove the old heartbeat, write a new one, and open their own WebSocket connection.
|
||||
|
||||
## Mock Infrastructure
|
||||
|
||||
All tests replace browser APIs with in-memory equivalents:
|
||||
|
||||
- **localStorage** — plain object (`mockStorage`) with `getItem`, `setItem`, `removeItem`, `clear`.
|
||||
- **setTimeout** — overridden to fire callbacks after 1ms instead of the specified delay, so backoff/retry logic runs instantly.
|
||||
- **WebSocket** — `MockWebSocket` auto-opens after 2ms, exposes `simulateServerPush()` and `simulateNetworkDrop()` helpers.
|
||||
- **BroadcastChannel** — `MockBroadcastChannel` delivers messages synchronously to all other registered channels, simulating cross-tab delivery.
|
||||
|
||||
## Extending the Test Suite
|
||||
|
||||
To add a new test:
|
||||
|
||||
1. Add a `describe`/`it` block or inline `assert` inside the relevant `test_<module>.js` file.
|
||||
2. For stream-related tests, ensure the file is imported via `--import ./test/test_setup.mjs`.
|
||||
3. Run the test from the project root with the appropriate command.
|
||||
|
||||
To add a new module test:
|
||||
|
||||
1. Create `test/test_<module>.js` with imports using `../<module>.js` (relative to the `/test` folder).
|
||||
2. Add the required mock infrastructure at the top.
|
||||
3. Register it in this README with its run command.
|
||||
4
test/test_setup.mjs
Normal file
4
test/test_setup.mjs
Normal file
@ -0,0 +1,4 @@
|
||||
globalThis.location = {
|
||||
protocol: 'http:',
|
||||
host: 'localhost:8080'
|
||||
};
|
||||
151
test/test_stream.js
Normal file
151
test/test_stream.js
Normal file
@ -0,0 +1,151 @@
|
||||
import { WalletManager } from '../wallets.js';
|
||||
import { WalletStreamManager } from '../stream.js';
|
||||
|
||||
// --- 1. Environment Mocks (Storage & Timers) ---
|
||||
const mockStorage = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => { mockStorage[key] = String(value); },
|
||||
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
|
||||
};
|
||||
|
||||
// Speed up time so our backoff tests don't take 30 actual seconds to run
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (callback, delay) => {
|
||||
return originalSetTimeout(callback, 1); // Force all retries to happen instantly
|
||||
};
|
||||
|
||||
// Mock location for stream.js WS_URL evaluation
|
||||
globalThis.location = {
|
||||
protocol: 'http:',
|
||||
host: 'localhost:3000'
|
||||
};
|
||||
|
||||
// --- 2. The Mock WebSocket Server Mirror ---
|
||||
const mockInstances = [];
|
||||
let lastSentPayload = null;
|
||||
let connectionCount = 0;
|
||||
|
||||
class MockWebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0; // CONNECTING
|
||||
connectionCount++;
|
||||
mockInstances.push(this);
|
||||
|
||||
originalSetTimeout(() => {
|
||||
this.readyState = 1; // OPEN
|
||||
if (this.onopen) this.onopen();
|
||||
}, 2);
|
||||
}
|
||||
|
||||
send(data) {
|
||||
lastSentPayload = JSON.parse(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3; // CLOSED
|
||||
if (this.onclose) this.onclose({ code: 1000, reason: "Normal closure" });
|
||||
}
|
||||
|
||||
// Helper method for our test runner to mimic the server pushing data to client
|
||||
simulateServerPush(msgObject) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage({ data: JSON.stringify(msgObject) });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to simulate a sudden dirty network disconnect
|
||||
simulateNetworkDrop() {
|
||||
this.readyState = 3;
|
||||
if (this.onclose) this.onclose({ code: 1006, reason: "Abnormal Drop" });
|
||||
}
|
||||
}
|
||||
globalThis.WebSocket = MockWebSocket;
|
||||
|
||||
// --- 3. Test Runner Execution ---
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(`❌ FAIL: ${message}`);
|
||||
console.log(`✅ PASS: ${message}`);
|
||||
}
|
||||
|
||||
async function runStreamTests() {
|
||||
console.log("🚀 Starting WalletStreamManager Real-Time Test Suite...\n");
|
||||
localStorage.clear();
|
||||
|
||||
// Seed two dummy wallets to simulate an existing local state
|
||||
const wm = new WalletManager();
|
||||
wm.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Base Acc");
|
||||
wm.addWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin", "Satoshi Acc");
|
||||
|
||||
const stream = new WalletStreamManager(wm);
|
||||
stream.connect();
|
||||
|
||||
// Wait a few milliseconds for our mocked async connection handshakes to settle
|
||||
await new Promise(r => originalSetTimeout(r, 10));
|
||||
|
||||
// --- TEST 1: Multiplexed On-Open Subscription ---
|
||||
assert(connectionCount === 1, "WebSocket established exactly one connection");
|
||||
assert(lastSentPayload !== null, "Server received subscription frame on startup");
|
||||
assert(lastSentPayload.action === "subscribe", "Payload correctly set action to 'subscribe'");
|
||||
assert(lastSentPayload.subscriptions.length === 2, "Multiplexed frame packed all stored wallets successfully");
|
||||
|
||||
// --- TEST 2: Real-time Message Callback Routing ---
|
||||
let balanceUpdatedCalled = false;
|
||||
stream.on('balance_update', (msg) => {
|
||||
if (msg.address === "0x04f728C520C438A000f7A5E9d904F0e725FFAEFE") {
|
||||
balanceUpdatedCalled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Access the underlying mock instance to simulate the server pushing data
|
||||
const activeSocketInstance = stream._ws;
|
||||
activeSocketInstance.simulateServerPush({
|
||||
type: "balance_update",
|
||||
address: "0x04f728C520C438A000f7A5E9d904F0e725FFAEFE",
|
||||
chain: "base",
|
||||
balance: "50000000000"
|
||||
});
|
||||
|
||||
assert(balanceUpdatedCalled === true, "Incoming server events route seamlessly to their respective listeners");
|
||||
|
||||
// --- TEST 3: Callback Error Isolation Safeguard ---
|
||||
let secondaryListenerExecuted = false;
|
||||
stream.on('transfer', () => {
|
||||
throw new Error("CRASHING INTRUDER: I am a broken UI component callback!");
|
||||
});
|
||||
stream.on('transfer', () => {
|
||||
secondaryListenerExecuted = true;
|
||||
});
|
||||
|
||||
// Push a transfer event. The first listener throws an exception, but shouldn't halt execution.
|
||||
try {
|
||||
activeSocketInstance.simulateServerPush({ type: "transfer", tx_hash: "0xabc" });
|
||||
assert(secondaryListenerExecuted === true, "ERROR ISOLATION: Second event handler fired successfully despite preceding listener throwing a hard error");
|
||||
} catch (e) {
|
||||
assert(false, "The StreamManager leaked a callback exception and crashed the event loop");
|
||||
}
|
||||
|
||||
// --- TEST 4: Dynamic Delta Streaming Tracker Updates ---
|
||||
const dynamicWallet = { address: "H7vjR5vP6dfYuxUzoZ76yvBpx6Z3zM7p9qE1vRkWk3xp", chain: "solana" };
|
||||
stream.subscribeToNewWallet(dynamicWallet);
|
||||
|
||||
assert(lastSentPayload.action === "subscribe_wallet", "Dynamic frame used accurate 'subscribe_wallet' verb");
|
||||
assert(lastSentPayload.wallet?.address === dynamicWallet.address, "Dynamic delta subscription dispatched for runtime updates without connection cycling");
|
||||
|
||||
// --- TEST 5: Resilient Exponential Backoff Retry Loop ---
|
||||
console.log("\n🕵️♂️ Triggering a sudden network failure drop scenario...");
|
||||
const preDropCount = connectionCount;
|
||||
|
||||
stream._ws.simulateNetworkDrop(); // Crash the current open pipe
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15)); // Wait for fast-forward timers to run the retry loops
|
||||
|
||||
assert(connectionCount > preDropCount, "RESILIENCE: Stream Manager successfully caught connection drop and automatically invoked reconnection logic");
|
||||
|
||||
// Cleanup
|
||||
stream.disconnect();
|
||||
console.log("\n🎉 All core real-time streaming transport client tests passed!");
|
||||
}
|
||||
|
||||
runStreamTests().catch(console.error);
|
||||
122
test/test_tabs.js
Normal file
122
test/test_tabs.js
Normal file
@ -0,0 +1,122 @@
|
||||
import { WalletManager } from '../wallets.js';
|
||||
import { WalletStreamManager } from '../stream.js';
|
||||
|
||||
// --- 1. Infrastructure Mocks ---
|
||||
const mockStorage = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => { mockStorage[key] = String(value); },
|
||||
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
|
||||
};
|
||||
|
||||
// Accelerate timers so leader election (200ms) and backoff happen instantly
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (callback, delay) => originalSetTimeout(callback, 1);
|
||||
|
||||
// Standard WebSocket Mock
|
||||
class MockWebSocket {
|
||||
constructor(url) {
|
||||
this.readyState = 1; // Immediately OPEN
|
||||
originalSetTimeout(() => { if (this.onopen) this.onopen(); }, 1);
|
||||
}
|
||||
send(data) { globalLastSentPayload = JSON.parse(data); }
|
||||
close() { if (this.onclose) this.onclose({ code: 1000 }); }
|
||||
}
|
||||
globalThis.WebSocket = MockWebSocket;
|
||||
|
||||
// --- 2. The Cross-Tab Broadcast Router Mock ---
|
||||
const activeChannels = new Set();
|
||||
class MockBroadcastChannel {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
activeChannels.add(this);
|
||||
}
|
||||
postMessage(data) {
|
||||
// Distribute the message to all OTHER open channel instances simulation offline cross-tab delivery
|
||||
for (const channel of activeChannels) {
|
||||
if (channel !== this && channel.onmessage) {
|
||||
channel.onmessage({ data });
|
||||
}
|
||||
}
|
||||
}
|
||||
close() { activeChannels.delete(this); }
|
||||
}
|
||||
globalThis.BroadcastChannel = MockBroadcastChannel;
|
||||
|
||||
// Global tracking variables for assertions
|
||||
let globalLastSentPayload = null;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(`❌ FAIL: ${message}`);
|
||||
console.log(`✅ PASS: ${message}`);
|
||||
}
|
||||
|
||||
// --- 3. The Multi-Tab Simulation Scenario ---
|
||||
async function runMultiTabTest() {
|
||||
console.log("🚀 Starting Multi-Tab Synchronization & Leader Election Test Suite...\n");
|
||||
localStorage.clear();
|
||||
|
||||
// Seed initial wallet configuration
|
||||
const wm = new WalletManager();
|
||||
wm.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Shared Wallet");
|
||||
|
||||
// --- STEP 1: Boot First Tab ---
|
||||
console.log("▶️ Initializing Tab 1...");
|
||||
const tab1 = new WalletStreamManager(wm);
|
||||
tab1.connect();
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15)); // Await leader election window
|
||||
assert(tab1.isLeader === true, "Tab 1 detected no existing leaders and claimed leadership role.");
|
||||
assert(tab1.ws !== null, "Tab 1 successfully initialized a physical WebSocket network connection.");
|
||||
|
||||
// --- STEP 2: Boot Second Tab ---
|
||||
console.log("\n▶️ Initializing Tab 2 (Simulating a new browser window/tab)...");
|
||||
const tab2 = new WalletStreamManager(wm);
|
||||
tab2.connect();
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15));
|
||||
assert(tab2.isLeader === false, "Tab 2 discovered active Leader and assigned itself as a Follower.");
|
||||
assert(tab2.ws === undefined || tab2.ws === null, "Tab 2 kept its local WebSocket offline to save system resources.");
|
||||
|
||||
// --- STEP 3: Verify Event Cross-Broadcasting Flow ---
|
||||
let tab2ReceivedData = false;
|
||||
tab2.on('balance_update', (msg) => {
|
||||
if (msg.balance === "9999") tab2ReceivedData = true;
|
||||
});
|
||||
|
||||
// Simulate server pushing an update specifically to Tab 1's socket connection
|
||||
tab1.ws.onmessage({
|
||||
data: JSON.stringify({ type: "balance_update", balance: "9999" })
|
||||
});
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 5));
|
||||
assert(tab2ReceivedData === true, "Tab 1 received WebSocket data and pushed it locally across the BroadcastChannel to Tab 2.");
|
||||
|
||||
// --- STEP 4: Proxy Subscription Modification Check ---
|
||||
console.log("\n▶️ Simulating Follower (Tab 2) subscribing to a new asset dynamically...");
|
||||
const newAsset = { address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", chain: "bitcoin" };
|
||||
|
||||
globalLastSentPayload = null;
|
||||
tab2.subscribeToNewWallet(newAsset);
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 5));
|
||||
assert(globalLastSentPayload !== null, "Tab 2's subscription was successfully intercepted and routed to the channel.");
|
||||
assert(globalLastSentPayload.action === "subscribe_wallet", "Tab 1 successfully executed the proxy network subscription on behalf of Tab 2.");
|
||||
|
||||
// --- STEP 5: Graceful Failover Handover (Leader Death Scenario) ---
|
||||
console.log("\n🕵️♂️ Simulating user abruptly closing Tab 1 (Leader)...");
|
||||
|
||||
// Simulate browser exit/unload routine for Tab 1
|
||||
tab1.disconnect();
|
||||
// Dispatch the exit event if your module binds directly to window event listeners
|
||||
if (tab1._handleExit) tab1._handleExit();
|
||||
|
||||
await new Promise(r => originalSetTimeout(r, 15)); // Await emergency election frame
|
||||
|
||||
assert(tab2.isLeader === true, "Tab 2 caught the LEADER_DEATH signal and successfully promoted itself to Leader.");
|
||||
assert(tab2.ws !== null, "Tab 2 gracefully opened a brand new physical WebSocket connection to pick up the stream.");
|
||||
|
||||
console.log("\n🎉 Massive Success! Cross-tab synchronization, proxy forwarding, and leader failovers function perfectly.");
|
||||
}
|
||||
|
||||
runMultiTabTest().catch(console.error);
|
||||
76
test/test_verifier.js
Normal file
76
test/test_verifier.js
Normal file
@ -0,0 +1,76 @@
|
||||
import { WalletManager } from '../wallets.js';
|
||||
import { WalletVerifier } from '../verifier.js';
|
||||
|
||||
// Setup basic environment mocks
|
||||
const mockStorage = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => { mockStorage[key] = String(value); }
|
||||
};
|
||||
|
||||
function assertError(result, expectedMsg, testName) {
|
||||
if (result.success === false && result.error.includes(expectedMsg)) {
|
||||
console.log(`✅ PASS: ${testName}`);
|
||||
} else {
|
||||
console.error(`❌ FAIL: ${testName} (Got: "${result.error ?? 'success'}", Expected: "${expectedMsg}")`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runVerifierTests() {
|
||||
console.log("🚀 Starting WalletVerifier Error State Test Suite...\n");
|
||||
|
||||
// --- TEST 1: Missing Extension ---
|
||||
globalThis.window = {}; // No ethereum provider defined
|
||||
const wm1 = new WalletManager();
|
||||
let verifier = new WalletVerifier(wm1);
|
||||
assertError(
|
||||
await verifier.triggerWalletVerification('base', 'Test'),
|
||||
"No Web3 wallet extension found",
|
||||
"Missing Extension Detection"
|
||||
);
|
||||
|
||||
// --- TEST 2: User Rejects Account Connection (EIP-1193 Error 4001) ---
|
||||
globalThis.window = {
|
||||
ethereum: {
|
||||
request: async ({ method }) => {
|
||||
if (method === 'eth_requestAccounts') {
|
||||
const err = new Error("User denied connection");
|
||||
err.code = 4001;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const wm2 = new WalletManager();
|
||||
verifier = new WalletVerifier(wm2);
|
||||
assertError(
|
||||
await verifier.triggerWalletVerification('base', 'Test'),
|
||||
"Verification cancelled",
|
||||
"Account Rejection (Code 4001)"
|
||||
);
|
||||
|
||||
// --- TEST 3: User Rejects Signature ---
|
||||
globalThis.window = {
|
||||
ethereum: {
|
||||
request: async ({ method }) => {
|
||||
if (method === 'eth_requestAccounts') return ['0x04f728C520C438A000f7A5E9d904F0e725FFAEFE'];
|
||||
if (method === 'eth_signTypedData_v4') {
|
||||
const err = new Error("User rejected the signature request");
|
||||
err.code = 4001;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const wm3 = new WalletManager();
|
||||
verifier = new WalletVerifier(wm3);
|
||||
assertError(
|
||||
await verifier.triggerWalletVerification('base', 'Test'),
|
||||
"rejected",
|
||||
"Signature Rejection Mapping"
|
||||
);
|
||||
|
||||
console.log("\n🏁 Test Suite Complete.");
|
||||
}
|
||||
|
||||
runVerifierTests();
|
||||
80
test/test_wallets.js
Normal file
80
test/test_wallets.js
Normal file
@ -0,0 +1,80 @@
|
||||
import { WalletManager } from '../wallets.js';
|
||||
|
||||
// 1. Mock localStorage for the Node.js environment
|
||||
const mockStorage = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => mockStorage[key] || null,
|
||||
setItem: (key, value) => { mockStorage[key] = String(value); },
|
||||
removeItem: (key) => { delete mockStorage[key]; },
|
||||
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }
|
||||
};
|
||||
|
||||
// Simple test assertion helper
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(`❌ FAIL: ${message}`);
|
||||
console.log(`✅ PASS: ${message}`);
|
||||
}
|
||||
|
||||
async function runTestSuite() {
|
||||
console.log("🚀 Starting WalletManager Test Suite...\n");
|
||||
localStorage.clear();
|
||||
|
||||
const manager = new WalletManager();
|
||||
|
||||
// --- TEST 1: Address Format Validations ---
|
||||
const invalidResult = manager.addWallet("invalid-address", "base", "My EVM");
|
||||
assert(!invalidResult.success, "Rejected invalid EVM address format properly");
|
||||
|
||||
// Add valid addresses across different patterns
|
||||
assert(manager.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Main Base").success, "Added EVM wallet");
|
||||
assert(manager.addWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin", "Satoshi BTC").success, "Added Bitcoin wallet");
|
||||
assert(manager.addWallet("H7vjR5vP6dfYuxUzoZ76yvBpx6Z3zM7p9qE1vRkWk3xp", "solana", "Solana Trading").success, "Added Solana wallet");
|
||||
console.log("👉 ACTUAL WALLETS IN STATE:", manager.getWallets());
|
||||
console.log("👉 CURRENT LENGTH:", manager.getWallets().length);
|
||||
assert(manager.getWallets().length === 3, "Successfully added 3 distinct multichain wallets");
|
||||
|
||||
// --- TEST 2: Duplicate Rejection ---
|
||||
const dupResult = manager.addWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base", "Duplicate Base");
|
||||
assert(!dupResult.success, "Correctly prevented duplicate entry");
|
||||
|
||||
// --- TEST 3: Verification Flow ---
|
||||
const initialWallet = manager.findWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base");
|
||||
assert(initialWallet.isVerified === false, "Wallet starts completely unverified");
|
||||
|
||||
manager.verifyWallet(
|
||||
"0x04f728C520C438A000f7A5E9d904F0e725FFAEFE",
|
||||
"base",
|
||||
"0xmockSignature123456",
|
||||
{ action: "Verify Ownership" }
|
||||
);
|
||||
|
||||
const verifiedWallet = manager.findWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base");
|
||||
assert(verifiedWallet.isVerified === true, "verifyWallet() successfully flips flag to true");
|
||||
assert(verifiedWallet.signature === "0xmockSignature123456", "Signature string is retained");
|
||||
|
||||
// --- TEST 4: The Core Security Hack Simulation ---
|
||||
console.log("\n🕵️♂️ Simulating a user manually tampering with localStorage file...");
|
||||
|
||||
// Fetch raw storage payload, hack it to true, and write it back
|
||||
const rawData = JSON.parse(localStorage.getItem("cbbtc_tracked_wallets"));
|
||||
rawData.forEach(w => w.isVerified = true); // Force-inject "true" directly to file
|
||||
localStorage.setItem("cbbtc_tracked_wallets", JSON.stringify(rawData));
|
||||
|
||||
// Instantiate a brand new manager to simulate page reload/reboot
|
||||
const maliciousManager = new WalletManager();
|
||||
maliciousManager.loadWallets(); // Boot up from hacked storage
|
||||
|
||||
const postBootWallet = maliciousManager.findWallet("0x04f728C520C438A000f7A5E9d904F0e725FFAEFE", "base");
|
||||
assert(postBootWallet.isVerified === false, "SECURITY VERIFIED: Tampered localStorage flags were successfully wiped back to false on boot.");
|
||||
|
||||
// --- TEST 5: Removal ---
|
||||
maliciousManager.removeWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin");
|
||||
assert(maliciousManager.findWallet("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "bitcoin") === undefined, "Successfully spliced and removed wallet from state");
|
||||
|
||||
console.log("\n🎉 All core client-side state tests passed successfully!");
|
||||
}
|
||||
|
||||
runTestSuite().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user