| … | … | @@ -8,11 +8,9 @@ |
| 8 | 8 | }, |
| 9 | 9 | "scripts": { |
| 10 | 10 | "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" | |
| 16 | 14 | }, |
| 17 | 15 | "pear": { |
| 18 | 16 | "name": "hyper-wallet-desktop", |
| … | … | @@ -0,0 +1,5 @@ |
| 1 | // test/all.cjs - Test runner | |
| 2 | // Run all tests with: bare test/all.cjs or npm test | |
| 3 | ||
| 4 | require('./encryption.cjs') | |
| 5 | require('./auth.cjs') |
| … | … | @@ -0,0 +1,163 @@ |
| 1 | // test/auth.cjs - Tests for lib/auth.cjs | |
| 2 | ||
| 3 | const test = require('brittle') | |
| 4 | const path = require('bare-path') | |
| 5 | const fs = require('bare-fs') | |
| 6 | const os = require('bare-os') | |
| 7 | const crypto = require('hypercore-crypto') | |
| 8 | const 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 | |
| 15 | function generateTestToken(bytes = 32) { | |
| 16 | return b4a.toString(crypto.randomBytes(bytes), 'hex') | |
| 17 | } | |
| 18 | ||
| 19 | test('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 | ||
| 29 | test('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 | ||
| 38 | test('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 | ||
| 59 | test('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 | ||
| 68 | test('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 | ||
| 91 | test('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 | ||
| 114 | test('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 | ||
| 128 | test('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 | ||
| 143 | test('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 | ||
| 155 | test('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 | }) |
| … | … | @@ -0,0 +1,129 @@ |
| 1 | // test/encryption.js - Tests for lib/encryption.cjs | |
| 2 | ||
| 3 | const test = require('brittle') | |
| 4 | const { encrypt, decrypt, isValidFormat } = require('../lib/encryption.cjs') | |
| 5 | const { eventFlush } = require('./helpers/index.cjs') | |
| 6 | ||
| 7 | test('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 | ||
| 23 | test('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 | ||
| 33 | test('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 | ||
| 45 | test('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 | ||
| 57 | test('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 | ||
| 69 | test('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 | ||
| 77 | test('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 | ||
| 88 | test('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 | ||
| 106 | test('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 | ||
| 119 | test('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 | }) |
| … | … | @@ -0,0 +1,104 @@ |
| 1 | // test/helpers/index.js - Shared test utilities and factories | |
| 2 | ||
| 3 | const crypto = require('hypercore-crypto') | |
| 4 | const b4a = require('b4a') | |
| 5 | const path = require('bare-path') | |
| 6 | const fs = require('bare-fs') | |
| 7 | ||
| 8 | /** | |
| 9 | * Create temporary directory for test | |
| 10 | * Automatically cleaned up after test via t.teardown() | |
| 11 | */ | |
| 12 | exports.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 | */ | |
| 20 | exports.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 | */ | |
| 68 | exports.eventFlush = async function () { | |
| 69 | await new Promise((resolve) => setImmediate(resolve)) | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Sleep for specified milliseconds | |
| 74 | */ | |
| 75 | exports.sleep = function (ms) { | |
| 76 | return new Promise(resolve => setTimeout(resolve, ms)) | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Generate test token | |
| 81 | */ | |
| 82 | exports.generateTestToken = function (bytes = 32) { | |
| 83 | return b4a.toString(crypto.randomBytes(bytes), 'hex') | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Create mock WDK instance (returns mocked WDK) | |
| 88 | */ | |
| 89 | exports.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 | */ | |
| 100 | exports.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 | } |