OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-desktop commits / 7c58b04

add encryption and auth tests (holepunchto pattern).
restructured tests to follow holepunchto testing paradigms: - focused test files (encryption.cjs, auth.cjs not unit/encryption.test.cjs) - test/helpers/index.cjs with shared factories and utilities - test/all.cjs runner that requires all test files - use t.teardown() for cleanup (holepunchto standard) - use t.tmp() for temporary directories - all tests run with bare runtime (not npm/node) - use real filesystem for auth tests (hardcoded paths) test results: - encryption.cjs: 10/10 tests, 24/24 assertions pass - auth.cjs: 10/10 tests, 18/18 assertions pass - total: 20/20 tests, 42/42 assertions pass (427ms) ready to continue with remaining modules (address-validator, amount-validator, input-sanitizer, rate-limiter). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
7c58b0457cdaedfde00b365ac09ae9e48ef06a5e
by plankatron <fleeky@gmail.com> · 2026-01-29T02:23:47.000Z

Changed files (5) +404 -5

package.json +3 -5
……@@ -8,11 +8,9 @@
88 },
99 "scripts": {
1010 "postinstall": "bash scripts/setup-pear-compat.sh",
11 "test": "brittle test/**/*.test.js",
12 "test:unit": "brittle test/unit/**/*.test.js",
13 "test:integration": "brittle test/integration/**/*.test.js",
14 "test:regression": "brittle test/regression/**/*.test.js",
15 "test:watch": "brittle --watch test/**/*.test.js"
11 "test": "bare test/all.cjs",
12 "test:encryption": "bare test/encryption.cjs",
13 "test:auth": "bare test/auth.cjs"
1614 },
1715 "pear": {
1816 "name": "hyper-wallet-desktop",
test/all.cjs +5 -0
……@@ -0,0 +1,5 @@
1// test/all.cjs - Test runner
2// Run all tests with: bare test/all.cjs or npm test
3
4require('./encryption.cjs')
5require('./auth.cjs')
test/auth.cjs +163 -0
……@@ -0,0 +1,163 @@
1// test/auth.cjs - Tests for lib/auth.cjs
2
3const test = require('brittle')
4const path = require('bare-path')
5const fs = require('bare-fs')
6const os = require('bare-os')
7const crypto = require('hypercore-crypto')
8const b4a = require('b4a')
9
10// Note: lib/auth.cjs uses hardcoded paths (~/.hyper-wallet/.api-token)
11// These tests verify the module works, but can't use mocking
12// Real file operations happen in user's home directory during tests
13
14// Helper to generate test tokens
15function generateTestToken(bytes = 32) {
16 return b4a.toString(crypto.randomBytes(bytes), 'hex')
17}
18
19test('generateToken produces 64-char hex', function (t) {
20 const { generateToken } = require('../lib/auth.cjs')
21 const token = generateToken()
22
23 t.ok(token, 'token exists')
24 t.is(typeof token, 'string', 'token is string')
25 t.is(token.length, 64, 'token is 64 characters')
26 t.ok(/^[0-9a-f]{64}$/.test(token), 'token is hex')
27})
28
29test('generateToken produces unique tokens', function (t) {
30 const { generateToken } = require('../lib/auth.cjs')
31
32 const token1 = generateToken()
33 const token2 = generateToken()
34
35 t.not(token1, token2, 'tokens are unique')
36})
37
38test('verifyToken accepts valid Bearer token', function (t) {
39 const { generateToken, saveToken, verifyToken } = require('../lib/auth.cjs')
40
41 const token = generateToken()
42 saveToken(token)
43
44 t.teardown(() => {
45 const { deleteToken } = require('../lib/auth.cjs')
46 deleteToken()
47 })
48
49 const req = {
50 headers: {
51 authorization: `Bearer ${token}`
52 }
53 }
54
55 const result = verifyToken(req)
56 t.ok(result, 'valid token accepted')
57})
58
59test('verifyToken rejects missing authorization header', function (t) {
60 const { verifyToken } = require('../lib/auth.cjs')
61
62 const req = { headers: {} }
63 const result = verifyToken(req)
64
65 t.absent(result, 'rejects missing header')
66})
67
68test('verifyToken rejects malformed Bearer header', function (t) {
69 const { generateToken, saveToken, verifyToken } = require('../lib/auth.cjs')
70
71 saveToken(generateToken())
72
73 t.teardown(() => {
74 const { deleteToken } = require('../lib/auth.cjs')
75 deleteToken()
76 })
77
78 const testCases = [
79 { headers: { authorization: 'InvalidFormat' } },
80 { headers: { authorization: 'Bearer' } },
81 { headers: { authorization: 'Bearer ' } },
82 { headers: { authorization: 'Basic dXNlcjpwYXNz' } }
83 ]
84
85 for (const req of testCases) {
86 const result = verifyToken(req)
87 t.absent(result, `rejects: ${req.headers.authorization}`)
88 }
89})
90
91test('verifyToken rejects wrong token', function (t) {
92 const { generateToken, saveToken, verifyToken } = require('../lib/auth.cjs')
93
94 const correctToken = generateToken()
95 const wrongToken = generateToken()
96
97 saveToken(correctToken)
98
99 t.teardown(() => {
100 const { deleteToken } = require('../lib/auth.cjs')
101 deleteToken()
102 })
103
104 const req = {
105 headers: {
106 authorization: `Bearer ${wrongToken}`
107 }
108 }
109
110 const result = verifyToken(req)
111 t.absent(result, 'rejects wrong token')
112})
113
114test('tokenExists detects token file', function (t) {
115 const { generateToken, saveToken, tokenExists, deleteToken } = require('../lib/auth.cjs')
116
117 // Clean up first
118 deleteToken()
119
120 t.absent(tokenExists(), 'no token initially')
121
122 saveToken(generateToken())
123 t.teardown(() => deleteToken())
124
125 t.ok(tokenExists(), 'token exists after save')
126})
127
128test('saveToken creates file with 0600 permissions', function (t) {
129 const { generateToken, saveToken, deleteToken } = require('../lib/auth.cjs')
130
131 const token = generateToken()
132 saveToken(token)
133
134 t.teardown(() => deleteToken())
135
136 const tokenPath = path.join(os.homedir(), '.hyper-wallet', '.api-token')
137 t.ok(fs.existsSync(tokenPath), 'token file created')
138
139 const stat = fs.statSync(tokenPath)
140 t.is(stat.mode & 0o777, 0o600, 'token file has 0o600 permissions')
141})
142
143test('loadToken returns saved token', function (t) {
144 const { generateToken, saveToken, loadToken, deleteToken } = require('../lib/auth.cjs')
145
146 const testToken = generateToken()
147 saveToken(testToken)
148
149 t.teardown(() => deleteToken())
150
151 const loaded = loadToken()
152 t.is(loaded, testToken, 'loaded token matches saved')
153})
154
155test('loadToken returns null if file missing', function (t) {
156 const { loadToken, deleteToken } = require('../lib/auth.cjs')
157
158 // Ensure no token file
159 deleteToken()
160
161 const loaded = loadToken()
162 t.is(loaded, null, 'returns null for missing file')
163})
test/encryption.cjs +129 -0
……@@ -0,0 +1,129 @@
1// test/encryption.js - Tests for lib/encryption.cjs
2
3const test = require('brittle')
4const { encrypt, decrypt, isValidFormat } = require('../lib/encryption.cjs')
5const { eventFlush } = require('./helpers/index.cjs')
6
7test('encrypt produces valid base64', async function (t) {
8 const plaintext = 'test secret message'
9 const password = 'strong-password-123'
10
11 const encrypted = encrypt(plaintext, password)
12
13 t.ok(encrypted, 'encrypted output exists')
14 t.ok(typeof encrypted === 'string', 'encrypted is string')
15 t.ok(encrypted.length > plaintext.length, 'encrypted is longer than plaintext')
16 t.ok(/^[A-Za-z0-9+/=]+$/.test(encrypted), 'is valid base64')
17
18 const b4a = require('b4a')
19 const decoded = b4a.from(encrypted, 'base64')
20 t.ok(decoded.length >= 60, 'decoded has minimum length (salt 32 + iv 12 + tag 16)')
21})
22
23test('decrypt with correct password', async function (t) {
24 const plaintext = 'my secret wallet mnemonic'
25 const password = 'correct-password'
26
27 const encrypted = encrypt(plaintext, password)
28 const decrypted = decrypt(encrypted, password)
29
30 t.is(decrypted, plaintext, 'decrypted matches original')
31})
32
33test('decrypt throws with wrong password', async function (t) {
34 const plaintext = 'sensitive data'
35 const password = 'correct-password'
36 const wrongPassword = 'wrong-password'
37
38 const encrypted = encrypt(plaintext, password)
39
40 await t.exception(() => {
41 decrypt(encrypted, wrongPassword)
42 }, 'throws with wrong password')
43})
44
45test('decrypt throws with corrupted ciphertext', async function (t) {
46 const plaintext = 'test data'
47 const password = 'password123'
48
49 const encrypted = encrypt(plaintext, password)
50 const corrupted = encrypted.slice(0, -5) + 'XXXXX'
51
52 await t.exception(() => {
53 decrypt(corrupted, password)
54 }, 'throws with corrupted data')
55})
56
57test('decrypt throws with truncated data', async function (t) {
58 const plaintext = 'test'
59 const password = 'pass'
60
61 const encrypted = encrypt(plaintext, password)
62 const truncated = encrypted.slice(0, -20)
63
64 await t.exception(() => {
65 decrypt(truncated, password)
66 }, 'throws with truncated data')
67})
68
69test('decrypt throws with invalid base64', async function (t) {
70 const invalid = 'not-valid-base64!!!'
71
72 await t.exception(() => {
73 decrypt(invalid, 'password')
74 }, 'throws with invalid base64')
75})
76
77test('isValidFormat validates correct format', async function (t) {
78 const plaintext = 'test'
79 const password = 'password'
80
81 const encrypted = encrypt(plaintext, password)
82
83 t.ok(isValidFormat(encrypted), 'recognizes valid format')
84 t.absent(isValidFormat(''), 'rejects empty string')
85 t.absent(isValidFormat('!!!###'), 'rejects invalid base64')
86})
87
88test('round-trip encryption', async function (t) {
89 const testCases = [
90 'simple text',
91 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
92 '🔐 Unicode emoji test 日本語',
93 'a'.repeat(1000),
94 ''
95 ]
96
97 const password = 'test-password-xyz'
98
99 for (const plaintext of testCases) {
100 const encrypted = encrypt(plaintext, password)
101 const decrypted = decrypt(encrypted, password)
102 t.is(decrypted, plaintext, `round-trip: "${plaintext.slice(0, 20)}"`)
103 }
104})
105
106test('different passwords produce different ciphertexts', async function (t) {
107 const plaintext = 'same message'
108 const password1 = 'password-one'
109 const password2 = 'password-two'
110
111 const encrypted1 = encrypt(plaintext, password1)
112 const encrypted2 = encrypt(plaintext, password2)
113
114 t.not(encrypted1, encrypted2, 'different ciphertexts')
115 t.is(decrypt(encrypted1, password1), plaintext, 'first decrypts')
116 t.is(decrypt(encrypted2, password2), plaintext, 'second decrypts')
117})
118
119test('random IV makes ciphertexts different', async function (t) {
120 const plaintext = 'repeated message'
121 const password = 'same-password'
122
123 const encrypted1 = encrypt(plaintext, password)
124 const encrypted2 = encrypt(plaintext, password)
125
126 t.not(encrypted1, encrypted2, 'random IV changes output')
127 t.is(decrypt(encrypted1, password), plaintext, 'first decrypts')
128 t.is(decrypt(encrypted2, password), plaintext, 'second decrypts')
129})
test/helpers/index.cjs +104 -0
……@@ -0,0 +1,104 @@
1// test/helpers/index.js - Shared test utilities and factories
2
3const crypto = require('hypercore-crypto')
4const b4a = require('b4a')
5const path = require('bare-path')
6const fs = require('bare-fs')
7
8/**
9 * Create temporary directory for test
10 * Automatically cleaned up after test via t.teardown()
11 */
12exports.createTempDir = async function (t) {
13 const tmpDir = await t.tmp()
14 return tmpDir
15}
16
17/**
18 * Create mock encrypted files in temp directory
19 */
20exports.createMockWallet = async function (t, opts = {}) {
21 const tmpDir = await t.tmp()
22 const walletDir = path.join(tmpDir, '.hyper-wallet')
23
24 fs.mkdirSync(walletDir, { recursive: true, mode: 0o700 })
25
26 const {
27 identityMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art',
28 walletMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
29 token = b4a.toString(crypto.randomBytes(32), 'hex')
30 } = opts
31
32 if (identityMnemonic) {
33 fs.writeFileSync(
34 path.join(walletDir, '.identity-mnemonic'),
35 identityMnemonic,
36 { mode: 0o600 }
37 )
38 }
39
40 if (walletMnemonic) {
41 fs.writeFileSync(
42 path.join(walletDir, '.wallet-mnemonic'),
43 walletMnemonic,
44 { mode: 0o600 }
45 )
46 }
47
48 if (token) {
49 fs.writeFileSync(
50 path.join(walletDir, '.token'),
51 token,
52 { mode: 0o600 }
53 )
54 }
55
56 return {
57 tmpDir,
58 walletDir,
59 identityMnemonic,
60 walletMnemonic,
61 token
62 }
63}
64
65/**
66 * Event flush - wait for async operations to complete
67 */
68exports.eventFlush = async function () {
69 await new Promise((resolve) => setImmediate(resolve))
70}
71
72/**
73 * Sleep for specified milliseconds
74 */
75exports.sleep = function (ms) {
76 return new Promise(resolve => setTimeout(resolve, ms))
77}
78
79/**
80 * Generate test token
81 */
82exports.generateTestToken = function (bytes = 32) {
83 return b4a.toString(crypto.randomBytes(bytes), 'hex')
84}
85
86/**
87 * Create mock WDK instance (returns mocked WDK)
88 */
89exports.createMockWDK = function (t, mnemonic) {
90 const { MockWDK } = require('../mocks/wdk-mock.cjs')
91 const wdk = new MockWDK(mnemonic)
92
93 // No teardown needed for mock - it's stateless
94 return wdk
95}
96
97/**
98 * Load test fixtures
99 */
100exports.loadFixture = function (name) {
101 const fixturePath = path.join(__dirname, '..', 'fixtures', `${name}.json`)
102 const content = fs.readFileSync(fixturePath, 'utf8')
103 return JSON.parse(content)
104}