Skip to content

Commit a135ebb

Browse files
committed
test: comprehensive GPG seed phrase backup tests (32 tests)
- seedphrase-backup.test.ts (5): browser download, openpgp round-trip, wrong password - backup.test.ts (12): encrypt/decrypt, filenames, edge cases, unicode passwords - backup-wallet.test.ts (7): Wallet class methods, READ_ONLY error, static decrypt - wallet-backup.test.js (8): CLI end-to-end with system gpg, error handling
1 parent cf4b46b commit a135ebb

File tree

4 files changed

+544
-0
lines changed

4 files changed

+544
-0
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* CLI wallet backup/decrypt tests
3+
*
4+
* These tests exercise the `coinpay wallet backup-seed` and
5+
* `coinpay wallet decrypt-backup` CLI commands using system gpg.
6+
* Tests are skipped if gpg is not installed.
7+
*/
8+
9+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
10+
import { execSync } from 'child_process';
11+
import { existsSync, mkdtempSync } from 'fs';
12+
import { join } from 'path';
13+
import { tmpdir } from 'os';
14+
15+
const CLI_PATH = join(import.meta.dirname, '..', 'bin', 'coinpay.js');
16+
17+
// Check if gpg is available
18+
let hasGpg = false;
19+
try {
20+
execSync('gpg --version', { stdio: 'pipe' });
21+
hasGpg = true;
22+
} catch {
23+
hasGpg = false;
24+
}
25+
26+
/**
27+
* Run the CLI and capture merged stdout+stderr.
28+
* We merge via shell redirection since the CLI uses both console.log and console.error.
29+
* Returns { output, status }.
30+
*/
31+
function runCLI(args, { cwd, expectFail } = {}) {
32+
const cmd = `node ${CLI_PATH} ${args} 2>&1`;
33+
const opts = {
34+
encoding: 'utf-8',
35+
stdio: ['pipe', 'pipe', 'pipe'],
36+
env: { ...process.env, COINPAY_API_KEY: 'cp_test_fake_key' },
37+
timeout: 30000,
38+
...(cwd ? { cwd } : {}),
39+
};
40+
41+
try {
42+
const output = execSync(cmd, opts);
43+
return { output, status: 0 };
44+
} catch (err) {
45+
if (expectFail) {
46+
return {
47+
output: (err.stdout || '') + (err.stderr || ''),
48+
status: err.status || 1,
49+
};
50+
}
51+
throw err;
52+
}
53+
}
54+
55+
describe('CLI wallet backup commands', () => {
56+
let tmpDir;
57+
58+
beforeAll(() => {
59+
tmpDir = mkdtempSync(join(tmpdir(), 'coinpay-test-'));
60+
});
61+
62+
afterAll(() => {
63+
try {
64+
execSync(`rm -rf "${tmpDir}"`);
65+
} catch {
66+
// best effort
67+
}
68+
});
69+
70+
describe.skipIf(!hasGpg)('with gpg available', () => {
71+
const testSeed =
72+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
73+
const testPassword = 'test-password-123';
74+
const testWalletId = 'wid-test-001';
75+
76+
it('backup-seed should create a .gpg file', () => {
77+
const outputPath = join(tmpDir, `wallet_${testWalletId}_seedphrase.txt.gpg`);
78+
79+
runCLI(
80+
`wallet backup-seed --seed "${testSeed}" --password "${testPassword}" --wallet-id "${testWalletId}" --output "${outputPath}"`
81+
);
82+
83+
expect(existsSync(outputPath)).toBe(true);
84+
});
85+
86+
it('decrypt-backup should recover the seed phrase', () => {
87+
const outputPath = join(tmpDir, 'decrypt-roundtrip.txt.gpg');
88+
89+
runCLI(
90+
`wallet backup-seed --seed "${testSeed}" --password "${testPassword}" --wallet-id "${testWalletId}" --output "${outputPath}"`
91+
);
92+
93+
const { output } = runCLI(
94+
`wallet decrypt-backup "${outputPath}" --password "${testPassword}" --json`
95+
);
96+
97+
const parsed = JSON.parse(output);
98+
expect(parsed.mnemonic).toBe(testSeed);
99+
});
100+
101+
it('decrypt-backup with wrong password should show error message', () => {
102+
const outputPath = join(tmpDir, 'wrong-pw-test.txt.gpg');
103+
104+
runCLI(
105+
`wallet backup-seed --seed "${testSeed}" --password "${testPassword}" --wallet-id "${testWalletId}" --output "${outputPath}"`
106+
);
107+
108+
// The CLI catches gpg errors and prints a user-friendly message
109+
// It may or may not exit non-zero depending on implementation
110+
const { output } = runCLI(
111+
`wallet decrypt-backup "${outputPath}" --password "wrong-password"`,
112+
{ expectFail: true }
113+
);
114+
115+
expect(output).toMatch(/failed|error|wrong/i);
116+
});
117+
118+
it('backup-seed should use default filename when --output is not provided', () => {
119+
const cmd = `wallet backup-seed --seed "${testSeed}" --password "${testPassword}" --wallet-id "default-name"`;
120+
runCLI(cmd, { cwd: tmpDir });
121+
122+
const expectedFile = join(tmpDir, 'wallet_default-name_seedphrase.txt.gpg');
123+
expect(existsSync(expectedFile)).toBe(true);
124+
});
125+
126+
it('decrypt-backup should include wallet ID in raw output', () => {
127+
const outputPath = join(tmpDir, 'walletid-check.txt.gpg');
128+
129+
runCLI(
130+
`wallet backup-seed --seed "${testSeed}" --password "${testPassword}" --wallet-id "${testWalletId}" --output "${outputPath}"`
131+
);
132+
133+
const { output } = runCLI(
134+
`wallet decrypt-backup "${outputPath}" --password "${testPassword}" --json`
135+
);
136+
137+
const parsed = JSON.parse(output);
138+
expect(parsed.raw).toContain(`Wallet ID: ${testWalletId}`);
139+
});
140+
});
141+
142+
describe('missing required flags', () => {
143+
it('backup-seed without --wallet-id should show error', () => {
144+
const { output } = runCLI(
145+
'wallet backup-seed --seed "test words" --password "pass"',
146+
{ expectFail: true }
147+
);
148+
149+
// The CLI prints "Required: --wallet-id <id>" via console.error
150+
expect(output).toMatch(/wallet-id/i);
151+
});
152+
153+
it('decrypt-backup without file path should show error or usage info', () => {
154+
const { output } = runCLI(
155+
'wallet decrypt-backup --password "pass"',
156+
{ expectFail: true }
157+
);
158+
159+
// The CLI prints "Backup file path required" + example
160+
expect(output).toMatch(/file|path|backup|required|example/i);
161+
});
162+
163+
it('decrypt-backup with nonexistent file should show error', () => {
164+
const { output } = runCLI(
165+
'wallet decrypt-backup /tmp/nonexistent-file-12345.gpg --password "pass"',
166+
{ expectFail: true }
167+
);
168+
169+
expect(output).toMatch(/not found|error/i);
170+
});
171+
});
172+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { WalletSDKError } from './errors';
3+
import { encryptSeedPhrase, decryptSeedPhrase } from './backup';
4+
5+
// The Wallet class constructor is private and factories make API calls,
6+
// so we test the backup methods by:
7+
// 1. Testing the static decryptBackup independently (it's a thin wrapper)
8+
// 2. Testing that exportEncryptedBackup throws READ_ONLY when no mnemonic
9+
// 3. Round-trip testing using the underlying backup module functions
10+
11+
// We need to import Wallet to test its static method and instance behavior
12+
// We'll mock the API client to avoid network calls
13+
vi.mock('./client', () => ({
14+
WalletAPIClient: vi.fn().mockImplementation(() => ({
15+
request: vi.fn().mockResolvedValue({
16+
wallet_id: 'test-wallet-id',
17+
created_at: new Date().toISOString(),
18+
addresses: [],
19+
}),
20+
setSignatureAuth: vi.fn(),
21+
setJWTToken: vi.fn(),
22+
clearAuth: vi.fn(),
23+
})),
24+
hexToUint8Array: vi.fn((hex: string) => new Uint8Array(hex.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) || [])),
25+
uint8ArrayToHex: vi.fn((arr: Uint8Array) => Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('')),
26+
}));
27+
28+
// Mock the key derivation modules to avoid heavy crypto during tests
29+
vi.mock('../web-wallet/keys', () => ({
30+
generateMnemonic: vi.fn().mockReturnValue(
31+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
32+
),
33+
isValidMnemonic: vi.fn().mockReturnValue(true),
34+
deriveWalletBundle: vi.fn().mockResolvedValue({
35+
publicKeySecp256k1: 'mock-pub-key-secp',
36+
publicKeyEd25519: 'mock-pub-key-ed',
37+
privateKeySecp256k1: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
38+
addresses: [],
39+
}),
40+
deriveKeyForChain: vi.fn(),
41+
}));
42+
43+
vi.mock('../web-wallet/signing', () => ({
44+
signTransaction: vi.fn(),
45+
}));
46+
47+
vi.mock('../web-wallet/identity', () => ({
48+
buildDerivationPath: vi.fn().mockReturnValue("m/44'/60'/0'/0/0"),
49+
}));
50+
51+
import { Wallet } from './wallet';
52+
53+
describe('Wallet backup methods', () => {
54+
const testMnemonic =
55+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
56+
const testPassword = 'Str0ng!P@ssword';
57+
58+
describe('Wallet.decryptBackup (static)', () => {
59+
it('should decrypt data encrypted by encryptSeedPhrase', async () => {
60+
const { data } = await encryptSeedPhrase(testMnemonic, testPassword, 'test-wallet');
61+
const decrypted = await Wallet.decryptBackup(data, testPassword);
62+
63+
expect(decrypted).toBe(testMnemonic);
64+
});
65+
66+
it('should return null for wrong password', async () => {
67+
const { data } = await encryptSeedPhrase(testMnemonic, testPassword, 'test-wallet');
68+
const decrypted = await Wallet.decryptBackup(data, 'wrong-password');
69+
70+
expect(decrypted).toBeNull();
71+
});
72+
73+
it('should return null for garbage data', async () => {
74+
const garbage = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]);
75+
const decrypted = await Wallet.decryptBackup(garbage, testPassword);
76+
77+
expect(decrypted).toBeNull();
78+
});
79+
});
80+
81+
describe('wallet.exportEncryptedBackup (instance)', () => {
82+
it('should throw WalletSDKError with READ_ONLY code for read-only wallets', async () => {
83+
// fromWalletId creates a read-only wallet (no mnemonic)
84+
const readOnlyWallet = Wallet.fromWalletId('test-id', {
85+
baseUrl: 'https://test.api.com',
86+
apiKey: 'test-key',
87+
});
88+
89+
await expect(readOnlyWallet.exportEncryptedBackup(testPassword))
90+
.rejects.toThrow(WalletSDKError);
91+
92+
try {
93+
await readOnlyWallet.exportEncryptedBackup(testPassword);
94+
} catch (err) {
95+
expect(err).toBeInstanceOf(WalletSDKError);
96+
expect((err as WalletSDKError).code).toBe('READ_ONLY');
97+
}
98+
});
99+
100+
it('should produce encrypted data when wallet has mnemonic (via create)', async () => {
101+
const wallet = await Wallet.create({
102+
baseUrl: 'https://test.api.com',
103+
apiKey: 'test-key',
104+
chains: ['BTC'],
105+
});
106+
107+
const backup = await wallet.exportEncryptedBackup(testPassword);
108+
109+
expect(backup.data).toBeInstanceOf(Uint8Array);
110+
expect(backup.data.length).toBeGreaterThan(0);
111+
expect(backup.filename).toMatch(/^wallet_.*_seedphrase\.txt\.gpg$/);
112+
expect(backup.walletId).toBeTruthy();
113+
});
114+
115+
it('should produce data that Wallet.decryptBackup can decrypt', async () => {
116+
const wallet = await Wallet.create({
117+
baseUrl: 'https://test.api.com',
118+
apiKey: 'test-key',
119+
chains: ['BTC'],
120+
});
121+
122+
const backup = await wallet.exportEncryptedBackup(testPassword);
123+
const decrypted = await Wallet.decryptBackup(backup.data, testPassword);
124+
125+
// The mock generates the known test mnemonic
126+
expect(decrypted).toBe(testMnemonic);
127+
});
128+
129+
it('should not decrypt with a wrong password', async () => {
130+
const wallet = await Wallet.create({
131+
baseUrl: 'https://test.api.com',
132+
apiKey: 'test-key',
133+
chains: ['BTC'],
134+
});
135+
136+
const backup = await wallet.exportEncryptedBackup(testPassword);
137+
const decrypted = await Wallet.decryptBackup(backup.data, 'wrong-pw');
138+
139+
expect(decrypted).toBeNull();
140+
});
141+
});
142+
});

0 commit comments

Comments
 (0)