diff --git a/src/commands/balance.js b/src/commands/balance.js index 9d44bdd..3e4b69d 100644 --- a/src/commands/balance.js +++ b/src/commands/balance.js @@ -59,9 +59,14 @@ class Balance extends Command { let isFile = false let isValidFile = false const path = address + let walletJson + // The read belongs inside the try: existsSync says yes to anything on disk, including a + // directory, and reading one throws. Left outside, that escaped as a raw EISDIR instead + // of the message below. try { if (fs.existsSync(path)) { isFile = true + walletJson = openWalletFile(path) } } catch (error) { this.log(`${red('⨉')} Unable to get a balance: invalid QRL address/wallet file`) @@ -71,7 +76,6 @@ class Balance extends Command { this.log(`${red('⨉')} Unable to get a balance: invalid QRL address/wallet file`) this.exit(1) } else { - const walletJson = openWalletFile(path) try { if (walletJson.encrypted === false) { isValidFile = true @@ -93,6 +97,10 @@ class Balance extends Command { } } } catch (error) { + // The v2 wallet format is authenticated, so a wrong password makes decryption + // throw rather than return nonsense. Swallowing it here exited 1 with nothing + // printed at all - no hint that the password was the problem. + this.log(`${red('⨉')} Error decrypting wallet: ${error.message}`) this.exit(1) } if (!flags.json) { diff --git a/src/commands/create-wallet.js b/src/commands/create-wallet.js index c9f9325..cfb4fc1 100644 --- a/src/commands/create-wallet.js +++ b/src/commands/create-wallet.js @@ -59,21 +59,27 @@ class CreateWallet extends Command { return b32Encode(descriptorAndHash) } - const waitForQRLLIB = callBack => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) + // Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can + // await the work instead of returning while it is still going. Without that, a this.exit() + // inside the callback surfaces as an unhandled rejection rather than an exit code. + const waitForQRLLIB = callBack => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) - } + poll() + }) - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { // default to a tree height of 10 unless passed via CLI let xmssHeight = 10 if (flags.height) { diff --git a/src/commands/dump-transactions.js b/src/commands/dump-transactions.js index 0fdf02a..a7fc0bd 100644 --- a/src/commands/dump-transactions.js +++ b/src/commands/dump-transactions.js @@ -124,9 +124,14 @@ class DumpTransactions extends Command { let isFile = false let isValidFile = false const path = address + let walletJson + // The read belongs inside the try: existsSync says yes to anything on disk, including a + // directory, and reading one throws. Left outside, that escaped as a raw EISDIR instead + // of the message below. try { if (fs.existsSync(path)) { isFile = true + walletJson = openWalletFile(path) } } catch (error) { this.log(`${red('⨉')} Unable to dump transactions: invalid QRL address/wallet file - ${error.message}`) @@ -136,7 +141,6 @@ class DumpTransactions extends Command { this.log(`${red('⨉')} Unable to dump transactions: invalid QRL address/wallet file`) this.exit(1) } else { - const walletJson = openWalletFile(path) try { if (walletJson.encrypted === false) { isValidFile = true @@ -247,13 +251,12 @@ class DumpTransactions extends Command { hasMorePages = false } else { currentPage += 1 - // Rate limiting: 5 second pause between pages - if (hasMorePages) { - const pauseSpinner = ora({ text: 'Pausing 5 seconds to avoid hitting API limits...' }).start() - // eslint-disable-next-line no-await-in-loop - await sleep(5000) - pauseSpinner.succeed('Pause completed') - } + // Rate limiting: 5 second pause between pages. This arm is only reached while + // there are more pages to fetch, so no further check is needed. + const pauseSpinner = ora({ text: 'Pausing 5 seconds to avoid hitting API limits...' }).start() + // eslint-disable-next-line no-await-in-loop + await sleep(5000) + pauseSpinner.succeed('Pause completed') } } else { hasMorePages = false diff --git a/src/commands/generate-lattice-keys.js b/src/commands/generate-lattice-keys.js index d4833bd..67d8960 100644 --- a/src/commands/generate-lattice-keys.js +++ b/src/commands/generate-lattice-keys.js @@ -21,47 +21,65 @@ let QRLLIBLoaded = false let DILLIBLoaded = false let KYBLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} - -const waitForDILLIB = (callBack) => { - setTimeout(() => { - // Test the DILLIB object has the getString function. - // This is sufficient to tell us DILLIB has loaded. - if (typeof DILLIB.getString === 'function' && DILLIBLoaded === true) { - callBack() - } else { - DILLIBLoaded = true - return waitForDILLIB(callBack) + poll() + }) + +// Resolves once DILLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForDILLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the DILLIB object has the getString function. + // This is sufficient to tell us DILLIB has loaded. + if (typeof DILLIB.getString === 'function' && DILLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + DILLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} - -const waitForKYBLIB = (callBack) => { - setTimeout(() => { - // Test the KYBLIB object has the getString function. - // This is sufficient to tell us KYBLIB has loaded. - if (typeof KYBLIB.getString === 'function' && KYBLIBLoaded === true) { - callBack() - } else { - KYBLIBLoaded = true - return waitForKYBLIB(callBack) + poll() + }) + +// Resolves once KYBLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForKYBLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the KYBLIB object has the getString function. + // This is sufficient to tell us KYBLIB has loaded. + if (typeof KYBLIB.getString === 'function' && KYBLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + KYBLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) // Convert bytes to hex function bytesToHex(byteArray) { @@ -101,6 +119,7 @@ class Lattice extends Command { // open wallet file if (flags.wallet) { let isValidFile = false + let badPassword = false let walletJson try { // Inside the try: a missing or malformed file must reach the "invalid wallet file" @@ -119,18 +138,27 @@ class Lattice extends Command { } else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } else { - this.log(`${red('⨉')} Unable to open wallet file: invalid password`) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + this.log(`${red('⨉')} Unable to open wallet file: invalid password`) + this.exit(1) + } if (!isValidFile) { this.log(`${red('⨉')} Unable to open wallet file: invalid wallet file`) this.exit(1) @@ -178,26 +206,36 @@ class Lattice extends Command { } // set the fee to default or flag - let fee = 0 // default fee 100 Shor + let fee = 0 // default fee 0 Shor if (flags.fee) { const passedFee = parseInt(flags.fee, 10) - if (passedFee) { - fee = passedFee - } else { + // Rejected on being unusable, not on being falsy: parseInt('0') is 0, and a zero + // fee is both legal on the network and what this command uses when -f is omitted. + // Testing truthiness sent an explicit -f 0 down the "invalid" path. + if (Number.isNaN(passedFee) || passedFee < 0) { this.log(`${red('⨉')} Fee is invalid`) this.exit(1) } + fee = passedFee } // create the keys const spinner = ora({text: 'Creating Crystals Keys...'}).start() - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { // get the xmss pub key to send from let XMSS_OBJECT - if (hexseed.match(' ') === null) { - XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) - } else { - XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + // QRLLIB throws an emscripten pointer (a bare number), not an Error, so there is no + // message to relay and nothing useful to show the user. Without this catch the command + // exited non-zero having printed nothing at all. + try { + if (hexseed.match(' ') === null) { + XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) + } else { + XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + } + } catch (err) { + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') + this.exit(1) } const xmssPK = Buffer.from(XMSS_OBJECT.getPK(), 'hex') spinner.succeed('XMSS Key') @@ -208,14 +246,14 @@ class Lattice extends Command { const ecdsaPK = Buffer.from(publicKey) spinner.succeed('ECDSA PK created') - waitForKYBLIB(async () => { + await waitForKYBLIB(async () => { // new kyber keys const KYB_OBJECT = await new KYBLIB.Kyber.empty() const kyberPK = Buffer.from(KYB_OBJECT.getPK(), 'hex') const kyberSK = Buffer.from(KYB_OBJECT.getSK(), 'hex') spinner.succeed('Kyber Keys Created!') - waitForDILLIB(async () => { + await waitForDILLIB(async () => { // new dilithium keys const DIL_OBJECT = await new DILLIB.Dilithium.empty() const dilithiumPK = Buffer.from(DIL_OBJECT.getPK(), 'hex') @@ -356,13 +394,7 @@ class Lattice extends Command { const response = await Qrlnetwork.api('PushTransaction', pushTransactionReq) // this.log(`response: ${response}`) if (response.error_code && response.error_code !== 'SUBMITTED') { - let errorMessage = 'unknown error' - if (response.error_code) { - errorMessage = `Unable send push transaction [error: ${response.error_description}` - } else { - errorMessage = `Node rejected signed message: has OTS key ${flags.otsindex} been reused?` - } - spinner3.fail(`${errorMessage}]`) + spinner3.fail(`Unable send push transaction [error: ${response.error_description}]`) this.exit(1) } const pushTransactionRes = JSON.stringify(response.tx_hash) @@ -497,7 +529,7 @@ Lattice.flags = { fee: flags.string({ char: 'f', required: false, - description: '(default: 100) QRL (f)ee for transaction in Shor' + description: '(default: 0) QRL (f)ee for transaction in Shor' }), otsindex: flags.string({ diff --git a/src/commands/generate-shared-keys.js b/src/commands/generate-shared-keys.js index bdd87f6..94a69b9 100644 --- a/src/commands/generate-shared-keys.js +++ b/src/commands/generate-shared-keys.js @@ -65,46 +65,64 @@ const openEphemeralFile = function oEF(path) { return JSON.parse(contents)[0] } -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} -const waitForKYBLIB = (callBack) => { - setTimeout(() => { - // Test the KYBLIB object has the getString function. - // This is sufficient to tell us KYBLIB has loaded. - if (typeof KYBLIB.getString === 'function' && KYBLIBLoaded === true) { - callBack() - } else { - KYBLIBLoaded = true - return waitForKYBLIB(callBack) + poll() + }) +// Resolves once KYBLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForKYBLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the KYBLIB object has the getString function. + // This is sufficient to tell us KYBLIB has loaded. + if (typeof KYBLIB.getString === 'function' && KYBLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + KYBLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) -const waitForDILLIB = (callBack) => { - setTimeout(() => { - // Test the DILLIB object has the getString function. - // This is sufficient to tell us DILLIB has loaded. - if (typeof DILLIB.getString === 'function' && DILLIBLoaded === true) { - callBack() - } else { - DILLIBLoaded = true - return waitForDILLIB(callBack) +// Resolves once DILLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForDILLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the DILLIB object has the getString function. + // This is sufficient to tell us DILLIB has loaded. + if (typeof DILLIB.getString === 'function' && DILLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + DILLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) @@ -279,6 +297,9 @@ const checkLatticeJSON = (check) => { } return valid } + // Unreachable: arrayLength >= 2 guarantees the loop body runs, and every + // path through that body returns, so the loop can never fall through. + /* istanbul ignore next */ return valid } return valid @@ -288,10 +309,17 @@ const checkLatticeJSON = (check) => { function isFileEmpty(fileName, ignoreWhitespace=true) { return new Promise((resolve, reject) => { fs.readFile(fileName, (err, data) => { + // Reached when the path exists but cannot be read as a file - a directory, + // say. Callers await this now, so the rejection is theirs to report. if( err ) { reject(err); return; } + // The !ignoreWhitespace arm is unreachable through the public command: + // ignoreWhitespace defaults to true and no call site passes a second + // argument. Kept for a future internal caller that wants byte-exact + // emptiness rather than whitespace-only. + /* istanbul ignore next */ resolve((!ignoreWhitespace && data.length === 0) || (ignoreWhitespace && !!String(data).match(/^\s*$/))) }); }) @@ -327,16 +355,20 @@ class LatticeShared extends Command { // ///////////////////////// // 0.a Secret Lattice keys // ///////////////////////// + // Both latticePK and latticeSK are declared required, so oclif rejects the + // command before run() is ever called if either is missing (an empty string + // counts as missing too); the else arm cannot be reached from the CLI. + /* istanbul ignore else */ if (args.latticeSK) { // check if the secret keys are a file or json if (fs.existsSync(args.latticeSK)) { // file submitted, is file empty? - isFileEmpty(args.latticeSK).then( (isEmpty) => { - if (isEmpty) { - spinner.fail('File is empty...') - this.exit(1) - } - }) + // Awaited: unawaited, this raced the synchronous JSON.parse below, so an + // empty file was usually reported as unparseable rather than as empty. + if (await isFileEmpty(args.latticeSK)) { + spinner.fail('File is empty...') + this.exit(1) + } try{ latticeSK = openFile(args.latticeSK) } @@ -444,6 +476,8 @@ class LatticeShared extends Command { // 0.c Public Lattice keys // ///////////////////////// // Check for file, txhash or JSON + // Required arg, as above: this guard cannot be false from the CLI. + /* istanbul ignore else */ if (args.latticePK) { // check if the public keys are a file or json if (fs.existsSync(args.latticePK)) { @@ -517,8 +551,8 @@ class LatticeShared extends Command { const bobKyberPK = latticePK[pubKeyIndexNum].pk1 const bobECDSAPK = latticePK[pubKeyIndexNum].pk3 - waitForKYBLIB(async () => { - waitForDILLIB(async () => { + await waitForKYBLIB(async () => { + await waitForDILLIB(async () => { spinner.succeed(`Generating new shared secrets for`) spinner.succeed(`Address: ${latticePK[0].address}`) spinner.succeed(`Lattice Tx Hash: ${latticePK[pubKeyIndexNum].txHash}`) @@ -535,7 +569,9 @@ class LatticeShared extends Command { const sharedKey = KYBOBJECT_SENDER.getMyKey() spinner.succeed(`Secrets Generated, encrypting keys...`) // encrypt cyphertext with encrypted AES key - eccrypto.encrypt(Buffer.from(bobECDSAPK, 'hex'), Buffer.from(aliceCypherText)).then( function eccCypher(encryptedCypherText) { + // Awaited, and the callback returns its inner promise, so run() does not return + // while the keylist is still being written. + await eccrypto.encrypt(Buffer.from(bobECDSAPK, 'hex'), Buffer.from(aliceCypherText)).then( function eccCypher(encryptedCypherText) { const mykey = Uint8Array.from(Buffer.from(sharedKey.toString(), 'hex')) // Encrypt the seed *s* with shared key *key*. // The CTR counter is explicitly fixed at 1 (matching the decrypt @@ -555,7 +591,7 @@ class LatticeShared extends Command { fs.writeFileSync(signedMessage, signedMsgJson) spinner.succeed(`Signed Message File file written to: ${signedMessage}`) // 9 - Generate the next 1000 keys with Shake128 and shared secret seed - waitForQRLLIB(async () => { + return waitForQRLLIB(async () => { const sBin = QRLLIB.hstr2bin(Buffer.from(Buffer.from(seed).toString('hex'))) let keylist = QRLLIB.shake128(64000, sBin) if (flags.encryptPassword) { @@ -598,12 +634,11 @@ class LatticeShared extends Command { if (fs.existsSync(args.cypherText)) { // is file empty? // spinner.succeed('is the file empty? ') - isFileEmpty(args.cypherText).then( (isEmpty) => { - if (isEmpty) { - spinner.fail('Ciphertext File is empty...') - this.exit(1) - } - }) + // Awaited, as above: this used to race openEphemeralFile's JSON.parse. + if (await isFileEmpty(args.cypherText)) { + spinner.fail('Ciphertext File is empty...') + this.exit(1) + } encCypherTextJson = openEphemeralFile(args.cypherText) // check for valid json here validCypherTextJson = await checkCipherTextJson(encCypherTextJson) @@ -632,12 +667,11 @@ class LatticeShared extends Command { // is signedMessage a file? if (fs.existsSync(args.signedMessage)) { // is file empty? - isFileEmpty(args.signedMessage).then( (isEmpty) => { - if (isEmpty) { - spinner.fail('signedMessage File is empty...') - this.exit(1) - } - }) + // Awaited, as above: this used to race openEphemeralFile's JSON.parse. + if (await isFileEmpty(args.signedMessage)) { + spinner.fail('signedMessage File is empty...') + this.exit(1) + } signedMsgJson = openEphemeralFile(args.signedMessage) // check for valid json here validSignedMessageJson = await checkSignedMessageJson(signedMsgJson) @@ -664,16 +698,12 @@ class LatticeShared extends Command { spinner.succeed('Shared secrets found, decrypting and generating shared keylist') // Generate keys from found list using secret key and pub key from sender - waitForKYBLIB(async () => { - waitForDILLIB(async () => { - try { - encCypherText = encCypherTextJson - signedMsg = signedMsgJson - } - catch (error) { - spinner.fail('cant open files...') - this.exit(1) - } + await waitForKYBLIB(async () => { + await waitForDILLIB(async () => { + // Both were read and validated above; these are plain assignments, so there is + // nothing here that can fail. + encCypherText = encCypherTextJson + signedMsg = signedMsgJson // 1 - verify p signature using Alice's dilithium public key const verifySignedMsg = DILLIB.Dilithium.sign_open('', signedMsg, aliceDilithiumPK.toString('hex')) // if signature verified @@ -688,7 +718,9 @@ class LatticeShared extends Command { ciphertext: Buffer.from(encCypherText.ciphertext), mac: Buffer.from(encCypherText.mac), } - eccrypto.decrypt(Buffer.from(bobECDSASK.toString(), 'hex'), encCypherTextBuffer).then(function eccDecrypt(decCypherText) { + // Awaited, and the callback returns its inner promise, so run() does not return + // while the keylist is still being written. + await eccrypto.decrypt(Buffer.from(bobECDSASK.toString(), 'hex'), encCypherTextBuffer).then(function eccDecrypt(decCypherText) { // 4 - Bob kem_decodes with cyphertext to obtain shared key KYBOBJECT_RECEIVER.kem_decode(decCypherText.toString()) const sharedKey = KYBOBJECT_RECEIVER.getMyKey() @@ -700,7 +732,7 @@ class LatticeShared extends Command { const sDecrypted = aesCtr.decrypt(encryptedBytes) // Bob now has access to the seed s and the shared key sent from Alice // 6 - Generate the next 1000 keys with Shake, creating the same keylist as Alice has - waitForQRLLIB(async () => { + return waitForQRLLIB(async () => { const sBin = QRLLIB.hstr2bin(Buffer.from(Buffer.from(sDecrypted).toString('hex'))) const keyList = QRLLIB.shake128(64000, sBin) fs.writeFileSync(sharedKeyListFile, QRLLIB.bin2hstr(keyList), {mode: 0o600}) diff --git a/src/commands/list-transactions.js b/src/commands/list-transactions.js index 6ca3d6a..e504c1a 100644 --- a/src/commands/list-transactions.js +++ b/src/commands/list-transactions.js @@ -197,9 +197,14 @@ class ListTransactions extends Command { let isFile = false let isValidFile = false const path = address + let walletJson + // The read belongs inside the try: existsSync says yes to anything on disk, including a + // directory, and reading one throws. Left outside, that escaped as a raw EISDIR instead + // of the message below. try { if (fs.existsSync(path)) { isFile = true + walletJson = openWalletFile(path) } } catch (error) { this.log(`${red('⨉')} Unable to list transactions: invalid QRL address/wallet file - ${error.message}`) @@ -209,7 +214,6 @@ class ListTransactions extends Command { this.log(`${red('⨉')} Unable to list transactions: invalid QRL address/wallet file`) this.exit(1) } else { - const walletJson = openWalletFile(path) try { if (walletJson.encrypted === false) { isValidFile = true @@ -414,33 +418,29 @@ class ListTransactions extends Command { estimatedPages = currentPage } else { currentPage += 1 - // Rate limiting: 5 second pause between pages - if (hasMorePages) { - let countdown = 5 - let pauseSpinner - if (!flags.json) { - pauseSpinner = ora({ - text: `${white('Pausing')} ${green(countdown.toString())} ${white('seconds to respect API limits...')}` - }).start() - } - - const countdownInterval = setInterval(() => { - countdown -= 1 - if (pauseSpinner) { - if (countdown > 0) { - pauseSpinner.text = `${white('Pausing')} ${green(countdown.toString())} ${white('seconds to respect API limits...')}` - } else { - clearInterval(countdownInterval) - pauseSpinner.succeed('Ready for next page') - } - } else if (countdown <= 0) { - clearInterval(countdownInterval) - } - }, 1000) - - // eslint-disable-next-line no-await-in-loop - await sleep(5000) - } + // Rate limiting: 5 second pause between pages. Only reached while there are + // more pages to fetch, and only when --json is off - this whole arm lives + // inside `if (fetchSpinner)`, which is the same condition - so neither needs + // rechecking here. + let countdown = 5 + const pauseSpinner = ora({ + text: `${white('Pausing')} ${green(countdown.toString())} ${white('seconds to respect API limits...')}` + }).start() + + // The ticker only counts down; the sleep below owns finishing it. Letting the + // final tick do that raced the sleep - both are due at 5000ms - so the + // interval could outlive the pause and print into whatever came next. + const countdownInterval = setInterval(() => { + countdown -= 1 + pauseSpinner.text = + `${white('Pausing')} ${green(Math.max(countdown, 0).toString())} ` + + `${white('seconds to respect API limits...')}` + }, 1000) + + // eslint-disable-next-line no-await-in-loop + await sleep(5000) + clearInterval(countdownInterval) + pauseSpinner.succeed('Ready for next page') } } else if (response.transactions_detail.length < itemsPerPage) { hasMorePages = false diff --git a/src/commands/notarize.js b/src/commands/notarize.js index 76ad2d3..44078d1 100644 --- a/src/commands/notarize.js +++ b/src/commands/notarize.js @@ -47,25 +47,53 @@ function bytesToHex(byteArray) { let QRLLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) // Concatenates multiple typed arrays into one. // toUint8Vector // Take input and convert to unsigned uint64 bigendian bytes // Convert Binary object to Bytes +// With --json the progress spinners are stood down, but every call site uses the spinner +// unconditionally, so this stand-in takes their place rather than a `null`: it drops the +// progress chatter and still reports failures on stderr, so a --json run that goes wrong +// says why instead of exiting silently. (stdout stays JSON-only either way: ora writes to +// stderr, and only this.log reaches stdout.) succeed and fail are the only two methods this +// command calls on a spinner; anything else added later needs adding here too. +const quietSpinner = () => { + const self = { + succeed: () => self, + fail: (text) => { + // The command calls fail('') in one place purely to clear the spinner line. + if (text) { + process.stderr.write(`${text}\n`) + } + return self + }, + } + return self +} + +const startSpinner = (json, text) => (json ? quietSpinner() : ora({ text }).start()) + class Notarise extends Command { async run() { const { args, flags } = this.parse(Notarise) @@ -82,7 +110,10 @@ class Notarise extends Command { this.log(white().bgBlue(network)) } // the data to notarise here, can be a file submitted (path) or a string passed on cli - const spinner = flags.json ? null : ora({ text: 'Notarising Data...\n', }).start() + const spinner = startSpinner(flags.json, 'Notarising Data...\n') + // Unreachable: `dataHash` is declared as a required argument, and oclif rejects a missing or + // empty value before run() is entered. Kept as a guard for any future non-oclif caller. + /* istanbul ignore else */ if (args.dataHash) { const sha256regex = /^\b[A-Fa-f0-9]{64}\b/.test(args.dataHash) // is the passed data the correct length? should be a sha256 sum hash @@ -121,6 +152,7 @@ class Notarise extends Command { // open wallet file if (flags.wallet) { let isValidFile = false + let badPassword = false let walletJson try { // Inside the try: a missing or malformed file must reach the "invalid wallet file" @@ -140,20 +172,28 @@ class Notarise extends Command { else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } - else { - spinner.fail(`${black().bgRed(`Unable to open wallet file: Invalid password...`)}` ) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "Invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + spinner.fail(`${black().bgRed(`Unable to open wallet file: Invalid password...`)}` ) + this.exit(1) + } if (!isValidFile) { spinner.fail(`${black().bgRed(`Unable to open wallet file: Invalid wallet file...`)}` ) this.exit(1) @@ -189,6 +229,9 @@ class Notarise extends Command { } } // check ots for valid entry + // Defensive: reaching this line needs either --wallet or --hexseed, and both of those + // branches already exit when no OTS index was given, so the else can never be taken. + /* istanbul ignore else */ if (flags.otsindex) { const passedOts = parseInt(flags.otsindex, 10) if (!passedOts && passedOts !== 0) { @@ -200,22 +243,31 @@ class Notarise extends Command { let fee = 0 // default fee 0 Shor if (flags.fee) { const passedFee = parseInt(flags.fee, 10) - if (passedFee) { - fee = passedFee - } else { + // Rejected on being unusable, not on being falsy: parseInt('0') is 0, and a zero + // fee is both legal on the network and what this command uses when -f is omitted. + // Testing truthiness sent an explicit -f 0 down the "invalid" path. + if (Number.isNaN(passedFee) || passedFee < 0) { spinner.fail(`${black().bgRed(`Fee is invalid...`)}` ) this.exit(1) } + fee = passedFee } // sign and send transaction - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { let XMSS_OBJECT - if (hexseed.match(' ') === null) { - XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) - } - else { - XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + // QRLLIB throws an emscripten pointer (a bare number), not an Error, so there is no + // message to relay and nothing useful to show the user. Without this catch the command + // exited non-zero having printed nothing at all. + try { + if (hexseed.match(' ') === null) { + XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) + } else { + XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + } + } catch (err) { + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') + this.exit(1) } const xmssPK = Buffer.from(XMSS_OBJECT.getPK(), 'hex') spinner.succeed('xmssPK returned...') @@ -247,7 +299,7 @@ class Notarise extends Command { // send the message transaction with the notarise encoding to the node const message = await Qrlnetwork.api('GetMessageTxn', request) - const spinner3 = ora({ text: 'Signing transaction...' }).start() + const spinner3 = startSpinner(flags.json, 'Signing transaction...') // Preimage order is QRL core's MessageTransaction.get_data_bytes(): // master_addr || fee || message_hash || addr_to @@ -291,7 +343,7 @@ class Notarise extends Command { this.exit(1) } spinner3.succeed(`Node response matches the request. Transaction signed with OTS key ${flags.otsindex}. (nodes will reject this transaction if key reuse is detected)`) - const spinner4 = ora({ text: 'Pushing transaction to node...' }).start() + const spinner4 = startSpinner(flags.json, 'Pushing transaction to node...') // transaction sig and pub key into buffer returnedTx.signature = Buffer.from(signature) returnedTx.public_key = Buffer.from(xmssPK) // eslint-disable-line camelcase @@ -301,32 +353,29 @@ class Notarise extends Command { // push the transaction to the network const response = await Qrlnetwork.api('PushTransaction', pushTransactionReq) if (response.error_code && response.error_code !== 'SUBMITTED') { - let errorMessage = 'unknown error' - if (response.error_code) { - errorMessage = `Unable send push transaction [error: ${response.error_description}` - } else { - errorMessage = `Node rejected signed message: has OTS key ${flags.otsindex} been reused?` - } - spinner.fail(`${black().bgRed(`Qrlnetwork.api error: ${response.error_code}`)} ${errorMessage}` ) + spinner.fail( + `${black().bgRed(`Qrlnetwork.api error: ${response.error_code}`)} ` + + `Unable send push transaction [error: ${response.error_description}]` + ) this.exit(1) } const pushTransactionRes = JSON.stringify(response.tx_hash) const txhash = JSON.parse(pushTransactionRes) if (txnHash === bytesToHex(txhash.data)) { + // Reported for every network, not only the two public ones: a notarisation sent to a + // custom --grpc endpoint used to succeed and say nothing at all, so there was no way + // to find the transaction afterwards. + const txId = bytesToHex(txhash.data) + spinner4.succeed(`Transaction submitted to ${network} node: transaction ID: ${txId}`) // return link to explorer if (network === 'Mainnet') { - spinner4.succeed(`Transaction submitted to Mainnet node: transaction ID: ${bytesToHex(txhash.data)}`) - spinner3.succeed(`https://explorer.theqrl.org/tx/${bytesToHex(txhash.data)}`) - if (flags.json){ - this.log(`[{"tx_id":"${bytesToHex(txhash.data)}"}]`) - } + spinner3.succeed(`https://explorer.theqrl.org/tx/${txId}`) } else if (network === 'Testnet') { - spinner4.succeed(`Transaction submitted to Testnet node: transaction ID: ${bytesToHex(txhash.data)}`) - spinner3.succeed(`https://testnet-explorer.theqrl.org/tx/${bytesToHex(txhash.data)}`) - if (flags.json){ - this.log(`[{"tx_id":"${bytesToHex(txhash.data)}"}]`) - } + spinner3.succeed(`https://testnet-explorer.theqrl.org/tx/${txId}`) + } + if (flags.json){ + this.log(`[{"tx_id":"${txId}"}]`) } // this.exit(0) } diff --git a/src/commands/ots.js b/src/commands/ots.js index d945849..ffdb2eb 100644 --- a/src/commands/ots.js +++ b/src/commands/ots.js @@ -51,9 +51,14 @@ class OTSKey extends Command { let isFile = false let isValidFile = false const path = address + let walletJson + // The read belongs inside the try: existsSync says yes to anything on disk, including a + // directory, and reading one throws. Left outside, that escaped as a raw EISDIR instead + // of the message below. try { if (fs.existsSync(path)) { isFile = true + walletJson = openWalletFile(path) } } catch (error) { this.log(`${red('⨉')} Unable to get OTS: not a file`) @@ -63,7 +68,6 @@ class OTSKey extends Command { this.log(`${red('⨉')} Unable to get OTS: invalid QRL address/wallet file`) this.exit(1) } else { - const walletJson = openWalletFile(path) try { if (walletJson.encrypted === false) { isValidFile = true @@ -85,6 +89,10 @@ class OTSKey extends Command { } } } catch (error) { + // The v2 wallet format is authenticated, so a wrong password makes decryption + // throw rather than return nonsense. Swallowing it here exited 1 with nothing + // printed at all - no hint that the password was the problem. + this.log(`${red('⨉')} Error decrypting wallet: ${error.message}`) this.exit(1) } } diff --git a/src/commands/receive.js b/src/commands/receive.js index 5f56cf4..8d76e9d 100644 --- a/src/commands/receive.js +++ b/src/commands/receive.js @@ -21,9 +21,14 @@ class Receive extends Command { let isFile = false let isValidFile = false const path = address + let walletJson + // The read belongs inside the try: existsSync says yes to anything on disk, including a + // directory, and reading one throws. Left outside, that escaped as a raw EISDIR instead + // of the message below. try { if (fs.existsSync(path)) { isFile = true + walletJson = openWalletFile(path) } } catch (error) { this.log(`${red('⨉')} Invalid QRL address/wallet file`) @@ -33,7 +38,6 @@ class Receive extends Command { this.log(`${red('⨉')} Invalid QRL address/wallet file`) this.exit(1) } else { - const walletJson = openWalletFile(path) try { if (walletJson.encrypted === false) { isValidFile = true @@ -55,6 +59,10 @@ class Receive extends Command { } } } catch (error) { + // The v2 wallet format is authenticated, so a wrong password makes decryption + // throw rather than return nonsense. Swallowing it here exited 1 with nothing + // printed at all - no hint that the password was the problem. + this.log(`${red('⨉')} Error decrypting wallet: ${error.message}`) this.exit(1) } } diff --git a/src/commands/search.js b/src/commands/search.js index 5296bf2..7c6ceed 100644 --- a/src/commands/search.js +++ b/src/commands/search.js @@ -75,6 +75,9 @@ class Search extends Command { grpcEndpoint = 'mainnet-3.automated.theqrl.org:19009' network = 'Mainnet' } + // Unreachable: `search` is declared as a required argument, and oclif rejects a missing or + // empty value before run() is entered. Kept as a guard for any future non-oclif caller. + /* istanbul ignore if */ if (!args.search) { this.log(`${red('⨉')} No search string`) this.exit(1) diff --git a/src/commands/send-message.js b/src/commands/send-message.js index 26cd652..2fbfe24 100644 --- a/src/commands/send-message.js +++ b/src/commands/send-message.js @@ -16,19 +16,25 @@ const { signBoundTransaction, ResponseBindingError } = require('../functions/tx- let QRLLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) function string2Bin(str) { const result = []; @@ -103,6 +109,7 @@ class SendMessage extends Command { // open wallet file if (flags.wallet) { let isValidFile = false + let badPassword = false let walletJson try { // Inside the try: a missing or malformed file must reach the "invalid wallet file" @@ -122,19 +129,27 @@ class SendMessage extends Command { else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } - else { - this.log(`${red('⨉')} Unable to open wallet file: invalid password`) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + this.log(`${red('⨉')} Unable to open wallet file: invalid password`) + this.exit(1) + } if (!isValidFile) { this.log(`${red('⨉')} Unable to open wallet file: invalid wallet file`) this.exit(1) @@ -169,6 +184,9 @@ class SendMessage extends Command { } } // check ots for valid entry + // Defensive: reaching this line needs either --wallet or --hexseed, and both of those + // branches already exit when no OTS index was given, so the else can never be taken. + /* istanbul ignore else */ if (flags.otsindex) { const passedOts = parseInt(flags.otsindex, 10) if (!passedOts && passedOts !== 0) { @@ -177,24 +195,34 @@ class SendMessage extends Command { } } // set the fee to default or flag - let fee = 0 // default fee 100 Shor + let fee = 0 // default fee 0 Shor if (flags.fee) { const passedFee = parseInt(flags.fee, 10) - if (passedFee) { - fee = passedFee - } else { + // Rejected on being unusable, not on being falsy: parseInt('0') is 0, and a zero + // fee is both legal on the network and what this command uses when -f is omitted. + // Testing truthiness sent an explicit -f 0 down the "invalid" path. + if (Number.isNaN(passedFee) || passedFee < 0) { this.log(`${red('⨉')} Fee is invalid`) this.exit(1) } + fee = passedFee } const spinner = ora({ text: 'Sending Message to network...' }).start() - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { let XMSS_OBJECT - if (hexseed.match(' ') === null) { - XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) - } else { - XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + // QRLLIB throws an emscripten pointer (a bare number), not an Error, so there is no + // message to relay and nothing useful to show the user. Without this catch the command + // exited non-zero having printed nothing at all. + try { + if (hexseed.match(' ') === null) { + XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) + } else { + XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + } + } catch (err) { + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') + this.exit(1) } const xmssPK = Buffer.from(XMSS_OBJECT.getPK(), 'hex') spinner.succeed('xmssPK returned...') @@ -288,13 +316,7 @@ class SendMessage extends Command { // push the transaction to the network const response = await Qrlnetwork.api('PushTransaction', pushTransactionReq) if (response.error_code && response.error_code !== 'SUBMITTED') { - let errorMessage = 'unknown error' - if (response.error_code) { - errorMessage = `Unable send push transaction [error: ${response.error_description}` - } else { - errorMessage = `Node rejected signed message: has OTS key ${flags.otsindex} been reused?` - } - spinner4.fail(`${errorMessage}]`) + spinner4.fail(`Unable send push transaction [error: ${response.error_description}]`) this.exit(1) } const pushTransactionRes = JSON.stringify(response.tx_hash) @@ -387,7 +409,7 @@ SendMessage.flags = { fee: flags.string({ char: 'f', required: false, - description: 'QRL (f)ee for transaction in Shor (defaults to 100 Shor)' + description: 'QRL (f)ee for transaction in Shor (defaults to 0 Shor)' }), otsindex: flags.string({ diff --git a/src/commands/send.js b/src/commands/send.js index 7b0eaec..4a2c4d9 100644 --- a/src/commands/send.js +++ b/src/commands/send.js @@ -16,19 +16,25 @@ const { signBoundTransaction, ResponseBindingError } = require('../functions/tx- let QRLLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) const shorPerQuanta = 10 ** 9 @@ -141,11 +147,15 @@ class Send extends Command { message: 'Enter amount to send (in Quanta, or Shor if -s flag is set):', validate: value => value > 0 ? true : 'Quantity must be positive' }) - args.quantity = response.quantity.toString() - if (!args.quantity) { + // Checked before the conversion, not after. Two answers mean "no answer": a + // cancelled prompt returns nothing at all, and a blank submission returns an + // empty string, which the `>= 0` validator lets through as 0. Calling toString() + // on the first threw a TypeError one line ahead of the check meant to catch it. + if (response.quantity === undefined || response.quantity === '') { this.log(`${red('⨉')} Operation cancelled.`) this.exit(1) } + args.quantity = response.quantity.toString() } if (!flags.otsindex) { @@ -159,11 +169,15 @@ class Send extends Command { message: 'Enter OTS key index (e.g. 0):', validate: value => value >= 0 ? true : 'OTS index must be 0 or greater' }) - flags.otsindex = response.otsindex.toString() - if (!flags.otsindex) { + // Checked before the conversion, not after. Two answers mean "no answer": a + // cancelled prompt returns nothing at all, and a blank submission returns an + // empty string, which the `>= 0` validator lets through as 0. Calling toString() + // on the first threw a TypeError one line ahead of the check meant to catch it. + if (response.otsindex === undefined || response.otsindex === '') { this.log(`${red('⨉')} Operation cancelled.`) this.exit(1) } + flags.otsindex = response.otsindex.toString() } if (!flags.wallet && !flags.hexseed) { @@ -246,6 +260,10 @@ class Send extends Command { if (flags.loadfromfile) { sendMethods += 1 } + // Defensively unreachable: with no -F, the earlier gate exits (non-interactive) or the + // prompt sets flags.recipient / exits (interactive), so at least one send method is always + // set by the time we get here. Kept as a guard for future internal callers of this path. + /* istanbul ignore if */ if (sendMethods === 0) { this.log(`${red('⨉')} Unable to send: no recipients`) this.exit(1) @@ -264,6 +282,10 @@ class Send extends Command { this.exit(1) } } + // Defensively unreachable: the same condition is already handled above — non-interactively + // it exits with "Missing sender wallet file", interactively the prompt sets one of them or + // exits. Kept as a guard for future internal callers of this path. + /* istanbul ignore if */ if (!flags.wallet && !flags.hexseed && !flags.loadfromfile) { this.log(`${red('⨉')} Unable to send: no wallet json file, transaction file or hexseed specified`) this.exit(1) @@ -327,6 +349,7 @@ class Send extends Command { let address = '' if (flags.wallet) { let isValidFile = false + let badPassword = false let walletJson try { // Inside the try: a missing or malformed file must reach the "invalid wallet file" @@ -345,18 +368,27 @@ class Send extends Command { } else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } else { - this.log(`${red('⨉')} Unable to open wallet file: invalid password`) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + this.log(`${red('⨉')} Unable to open wallet file: invalid password`) + this.exit(1) + } if (!isValidFile) { this.log(`${red('⨉')} Unable to open wallet file: invalid wallet file`) this.exit(1) @@ -390,15 +422,17 @@ class Send extends Command { this.exit(1) } } - let fee = 0 // default fee 100 Shor + let fee = 0 // default fee 0 Shor if (flags.fee) { const passedFee = parseInt(flags.fee, 10) - if (passedFee) { - fee = passedFee - } else { + // Rejected on being unusable, not on being falsy: parseInt('0') is 0, and a zero + // fee is both legal on the network and what this command uses when -f is omitted. + // Testing truthiness sent an explicit -f 0 down the "invalid" path. + if (Number.isNaN(passedFee) || passedFee < 0) { this.log(`${red('⨉')} Fee is invalid`) this.exit(1) } + fee = passedFee } const thisAddressesTo = [] const thisAmounts = [] @@ -415,14 +449,22 @@ class Send extends Command { text = flags.savetofile ? 'QRLLIB loading...' : 'Sending unsigned transaction to node...' const spinner = ora({ text }).start() - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { let XMSS_OBJECT let xmssPK if (!flags.loadfromfile) { - if (hexseed.match(' ') === null) { - XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) - } else { - XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + // QRLLIB throws an emscripten pointer (a bare number), not an Error, so there is no + // message to relay and nothing useful to show the user. Without this catch the command + // exited non-zero having printed nothing at all. + try { + if (hexseed.match(' ') === null) { + XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) + } else { + XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + } + } catch (err) { + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') + this.exit(1) } xmssPK = Buffer.from(XMSS_OBJECT.getPK(), 'hex') } @@ -641,13 +683,7 @@ class Send extends Command { } const response = await Qrlnetwork.api('PushTransaction', pushTransactionReq) if (response.error_code && response.error_code !== 'SUBMITTED') { - let errorMessage = 'unknown error' - if (response.error_code) { - errorMessage = `Unable send push transaction [error: ${response.error_description}` - } else { - errorMessage = `Node rejected signed message: has OTS key ${flags.otsindex} been reused?` - } - spinner3.fail(`${errorMessage}]`) + spinner3.fail(`Unable send push transaction [error: ${response.error_description}]`) this.exit(1) } const pushTransactionRes = JSON.stringify(response.tx_hash) @@ -756,7 +792,7 @@ Send.flags = { fee: flags.string({ char: 'f', required: false, - description: 'Fee for transaction in Shor (defaults to 100 Shor)' + description: 'Fee for transaction in Shor (defaults to 0 Shor)' }), file: flags.string({ diff --git a/src/commands/sign-tx-offline.js b/src/commands/sign-tx-offline.js index e90557a..7ef528f 100644 --- a/src/commands/sign-tx-offline.js +++ b/src/commands/sign-tx-offline.js @@ -15,19 +15,25 @@ const aes = require('../utils/aes') let QRLLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - // Test the QRLLIB object has the str2bin function. - // This is sufficient to tell us QRLLIB has loaded. - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) const shorPerQuanta = 10 ** 9 @@ -260,9 +266,13 @@ class SignTxOffline extends Command { let hexseed = '' if (flags.wallet) { let isValidFile = false + let badPassword = false let address = '' - const walletJson = openWalletFile(flags.wallet) + let walletJson try { + // Read inside the try: left outside, a file that is not JSON escaped as a raw + // SyntaxError instead of reaching the "invalid wallet file" message below. + walletJson = openWalletFile(flags.wallet) if (walletJson.encrypted === false) { isValidFile = true address = walletJson.address @@ -275,18 +285,27 @@ class SignTxOffline extends Command { } else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } else { - this.log(`${red('⨉')} Unable to open wallet file: invalid password`) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + this.log(`${red('⨉')} Unable to open wallet file: invalid password`) + this.exit(1) + } if (!isValidFile) { this.log(`${red('⨉')} Unable to open wallet file: invalid wallet file`) this.exit(1) @@ -313,22 +332,25 @@ class SignTxOffline extends Command { } } } - if (flags.otsindex) { - const passedOts = parseInt(flags.otsindex, 10) - if (!passedOts && passedOts !== 0) { - this.log(`${red('⨉')} OTS key is invalid`) - this.exit(1) - } + // Unconditional, because --otsindex is a required flag: it is always present, and an + // empty one satisfies oclif while being falsy. Guarding on truthiness skipped this + // check for exactly that case and signed with parseInt('') === NaN. + const passedOts = parseInt(flags.otsindex, 10) + if (!passedOts && passedOts !== 0) { + this.log(`${red('⨉')} OTS key is invalid`) + this.exit(1) } let fee = 100 // default fee 100 Shor if (flags.fee) { const passedFee = parseInt(flags.fee, 10) - if (passedFee) { - fee = passedFee - } else { + // Rejected on being unusable, not on being falsy: parseInt('0') is 0, and a zero + // fee is both legal on the network and what this command uses when -f is omitted. + // Testing truthiness sent an explicit -f 0 down the "invalid" path. + if (Number.isNaN(passedFee) || passedFee < 0) { this.log(`${red('⨉')} Fee is invalid`) this.exit(1) } + fee = passedFee } const thisAddressesTo = [] const thisAmounts = [] @@ -342,12 +364,20 @@ class SignTxOffline extends Command { this.log(`Fee: ${fee} Shor`) const spinner = ora({ text: 'Signing transaction...' }).start() - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { let XMSS_OBJECT - if (hexseed.match(' ') === null) { - XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) - } else { - XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + // QRLLIB throws an emscripten pointer (a bare number), not an Error, so there is no + // message to relay and nothing useful to show the user. Without this catch the command + // exited non-zero having printed nothing at all. + try { + if (hexseed.match(' ') === null) { + XMSS_OBJECT = await new QRLLIB.Xmss.fromHexSeed(hexseed) + } else { + XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) + } + } catch (err) { + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') + this.exit(1) } const xmssPK = Buffer.from(XMSS_OBJECT.getPK(), 'hex') diff --git a/src/commands/token/create.js b/src/commands/token/create.js index ee8af45..29253d4 100644 --- a/src/commands/token/create.js +++ b/src/commands/token/create.js @@ -15,17 +15,25 @@ const { signBoundTransaction, ResponseBindingError } = require('../../functions/ let QRLLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) const openWalletFile = (path) => { const contents = fs.readFileSync(path) @@ -97,7 +105,12 @@ class TokenCreate extends Command { // 4. Initial Holder Balances const initialBalances = [] if (flags.holder) { - const holders = Array.isArray(flags.holder) ? flags.holder : [flags.holder] + // --holder is declared `multiple: true`, so oclif always hands over an array and the + // scalar arm below cannot be reached through the CLI. It stays as protection for a + // future in-process caller that passes a single holder string. + const holders = Array.isArray(flags.holder) + ? flags.holder + : /* istanbul ignore next */ [flags.holder] holders.forEach(h => { const parts = h.split(':') if (parts.length !== 2) { @@ -169,11 +182,15 @@ class TokenCreate extends Command { message: 'Enter OTS key index (e.g. 0):', validate: value => value >= 0 ? true : 'OTS index must be 0 or greater' }) - flags.otsindex = response.otsindex.toString() - if (!flags.otsindex) { + // Checked before the conversion, not after. Two answers mean "no answer": a + // cancelled prompt returns nothing at all, and a blank submission returns an + // empty string, which the `>= 0` validator lets through as 0. Calling toString() + // on the first threw a TypeError one line ahead of the check meant to catch it. + if (response.otsindex === undefined || response.otsindex === '') { this.log(`${red('⨉')} Operation cancelled.`) this.exit(1) } + flags.otsindex = response.otsindex.toString() } // 6. Wallet / Keys @@ -218,6 +235,7 @@ class TokenCreate extends Command { let address = '' if (flags.wallet) { let isValidFile = false + let badPassword = false let walletJson try { // Inside the try: a missing or malformed file must reach the "invalid wallet file" @@ -236,18 +254,27 @@ class TokenCreate extends Command { } else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } else { - this.log(`${red('⨉')} Unable to open wallet file: invalid password`) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + this.log(`${red('⨉')} Unable to open wallet file: invalid password`) + this.exit(1) + } if (!isValidFile) { this.log(`${red('⨉')} Unable to open wallet file: invalid wallet file`) this.exit(1) @@ -273,7 +300,7 @@ class TokenCreate extends Command { } const spinner = ora({ text: 'Connecting to QRL node...' }).start() - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { let XMSS_OBJECT try { if (hexseed.match(' ') === null) { @@ -284,7 +311,7 @@ class TokenCreate extends Command { XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) } } catch (err) { - spinner.fail(`Failed to recreate XMSS wallet object: ${err.message}`) + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') this.exit(1) } diff --git a/src/commands/token/transfer.js b/src/commands/token/transfer.js index 07b6dde..e48575c 100644 --- a/src/commands/token/transfer.js +++ b/src/commands/token/transfer.js @@ -15,17 +15,25 @@ const { signBoundTransaction, ResponseBindingError } = require('../../functions/ let QRLLIBLoaded = false -const waitForQRLLIB = (callBack) => { - setTimeout(() => { - if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { - callBack() - } else { - QRLLIBLoaded = true - return waitForQRLLIB(callBack) +// Resolves once QRLLIB has loaded *and* `callBack` has run to completion, so run() can +// await the work instead of returning while it is still going. Without that, a this.exit() +// inside the callback surfaces as an unhandled rejection rather than an exit code. +const waitForQRLLIB = (callBack) => + new Promise((resolve, reject) => { + const poll = () => { + setTimeout(() => { + // Test the QRLLIB object has the str2bin function. + // This is sufficient to tell us QRLLIB has loaded. + if (typeof QRLLIB.str2bin === 'function' && QRLLIBLoaded === true) { + Promise.resolve().then(callBack).then(resolve, reject) + } else { + QRLLIBLoaded = true + poll() + } + }, 50) } - return false - }, 50) -} + poll() + }) const openWalletFile = (path) => { const contents = fs.readFileSync(path) @@ -90,11 +98,15 @@ class TokenTransfer extends Command { message: 'Enter Amount of Tokens to Transfer:', validate: value => value > 0 ? true : 'Amount must be positive' }) - flags.amount = response.amount.toString() - if (!flags.amount) { + // Checked before the conversion, not after. Two answers mean "no answer": a + // cancelled prompt returns nothing at all, and a blank submission returns an + // empty string, which the `>= 0` validator lets through as 0. Calling toString() + // on the first threw a TypeError one line ahead of the check meant to catch it. + if (response.amount === undefined || response.amount === '') { this.log(`${red('⨉')} Operation cancelled.`) this.exit(1) } + flags.amount = response.amount.toString() } // 4. OTS index @@ -109,11 +121,15 @@ class TokenTransfer extends Command { message: 'Enter OTS key index (e.g. 0):', validate: value => value >= 0 ? true : 'OTS index must be 0 or greater' }) - flags.otsindex = response.otsindex.toString() - if (!flags.otsindex) { + // Checked before the conversion, not after. Two answers mean "no answer": a + // cancelled prompt returns nothing at all, and a blank submission returns an + // empty string, which the `>= 0` validator lets through as 0. Calling toString() + // on the first threw a TypeError one line ahead of the check meant to catch it. + if (response.otsindex === undefined || response.otsindex === '') { this.log(`${red('⨉')} Operation cancelled.`) this.exit(1) } + flags.otsindex = response.otsindex.toString() } // 5. Wallet / Keys @@ -158,6 +174,7 @@ class TokenTransfer extends Command { let address = '' if (flags.wallet) { let isValidFile = false + let badPassword = false let walletJson try { // Inside the try: a missing or malformed file must reach the "invalid wallet file" @@ -176,18 +193,27 @@ class TokenTransfer extends Command { } else { password = await cli.prompt('Enter password for wallet file', { type: 'hide' }) } - address = aes.decrypt(password, walletJson.address) - hexseed = aes.decrypt(password, walletJson.hexseed) - if (validateQrlAddress.hexString(address).result) { - isValidFile = true - } else { - this.log(`${red('⨉')} Unable to open wallet file: invalid password`) - this.exit(1) + // Two ways a wrong password shows up: the v2 format is authenticated, so decryption + // throws, and the legacy format is not, so it decrypts to nonsense that fails the + // address check. Both mean the password is wrong rather than the file. Reporting it + // from inside this try used to be swallowed by the catch below, which then printed + // "invalid wallet file" on top of it - two contradictory messages for one mistake. + try { + address = aes.decrypt(password, walletJson.address) + hexseed = aes.decrypt(password, walletJson.hexseed) + isValidFile = validateQrlAddress.hexString(address).result + } catch (error) { + isValidFile = false } + badPassword = !isValidFile } } catch (error) { isValidFile = false } + if (badPassword) { + this.log(`${red('⨉')} Unable to open wallet file: invalid password`) + this.exit(1) + } if (!isValidFile) { this.log(`${red('⨉')} Unable to open wallet file: invalid wallet file`) this.exit(1) @@ -213,7 +239,7 @@ class TokenTransfer extends Command { } const spinner = ora({ text: 'Connecting to QRL node...' }).start() - waitForQRLLIB(async () => { + await waitForQRLLIB(async () => { let XMSS_OBJECT try { if (hexseed.match(' ') === null) { @@ -224,7 +250,7 @@ class TokenTransfer extends Command { XMSS_OBJECT = await new QRLLIB.Xmss.fromMnemonic(hexseed) } } catch (err) { - spinner.fail(`Failed to recreate XMSS wallet object: ${err.message}`) + spinner.fail('Failed to recreate XMSS wallet object: invalid hexseed or mnemonic') this.exit(1) } diff --git a/src/functions/grpc.js b/src/functions/grpc.js index a0fabdf..ca31932 100644 --- a/src/functions/grpc.js +++ b/src/functions/grpc.js @@ -72,12 +72,10 @@ function loadGrpcBaseProto(grpcEndpoint) { prefix: 'qrl-', postfix: '.proto', }).name - writeFile(qrlProtoFilePath, res.grpcProto).then((fsErr) => { - if (fsErr) { - return null - } - return true - }) + // Awaited: the caller reads this file back to check its hash, so returning the path + // before the write lands is a race. A failed write rejects here rather than being + // reported through a callback argument a promise never passes. + await writeFile(qrlProtoFilePath, res.grpcProto) return qrlProtoFilePath }) } @@ -117,6 +115,10 @@ async function loadGrpcProto(protofile, endpoint) { async function makeClient(grpcEndpoint) { const proto = await loadGrpcBaseProto(grpcEndpoint) + // Defensive: loadGrpcBaseProto either rejects or resolves the name of a temp file it just + // created, which is never empty, so the else can't be reached through this module's only + // caller. The guard stays for any future path that resolves without a file. + /* istanbul ignore else */ if (proto) { const validHash = await checkProtoHash(proto) if (validHash) { diff --git a/test/commands/balance.test.js b/test/commands/balance.test.js index 41d1df5..258835f 100644 --- a/test/commands/balance.test.js +++ b/test/commands/balance.test.js @@ -1,9 +1,15 @@ const assert = require('assert') const {spawn} = require('child_process') +const crypto = require('crypto') const fs = require('fs') const testSetup = require('../test_setup') +// Suites that query a live QRL node (mainnet/testnet). Skipped in offline mode: the repo's +// existing pattern (see search.test.js / get-keys.test.js) so an offline pass never leaves the +// machine. The node-dependent code paths they cover are listed in the offline suite below. +const describeOnline = process.env.QRL_TEST_OFFLINE === 'true' ? describe.skip : describe + const processFlags = { detached: true, stdio: ['ignore', 'inherit', 'inherit'], @@ -125,6 +131,7 @@ describe('balance #4', () => { // bad encrypted address file password describe('balance #5', () => { let exitCode + let out = '' before((done) => { const args = [ 'balance', @@ -132,8 +139,14 @@ describe('balance #5', () => { '-p', 'notThePass', ] - const process = spawn('./bin/run', args, processFlags) - process.on('exit', (code) => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + child.stdout.on('data', (d) => { + out += d.toString() + }) + child.stderr.on('data', (d) => { + out += d.toString() + }) + child.on('exit', (code) => { exitCode = code done() }) @@ -141,6 +154,9 @@ describe('balance #5', () => { it('exit code should be non-0 if passed with bad address password- wrong password', () => { assert.notStrictEqual(exitCode, 0) }) + it('says the password was the problem, rather than exiting silently', () => { + assert.ok(/Error decrypting wallet/.test(out), `expected a reason\n--- actual ---\n${out}`) + }) }) describe('balance #6', () => { @@ -191,7 +207,7 @@ describe('balance #7', () => { // pass // mainnet balance -describe('balance #8', () => { +describeOnline('balance #8', () => { let exitCode before(async function balanceTest8() { this.timeout(15000) @@ -214,7 +230,7 @@ describe('balance #8', () => { }) -describe('balance #9', () => { +describeOnline('balance #9', () => { let exitCode before(async function balanceTest9() { this.timeout(15000) @@ -238,7 +254,7 @@ describe('balance #9', () => { }) // success -q -describe('balance #10', () => { +describeOnline('balance #10', () => { let exitCode before(async function balanceTest10() { this.timeout(15000) @@ -262,7 +278,7 @@ describe('balance #10', () => { }) // success testnet -describe('balance #11', () => { +describeOnline('balance #11', () => { let exitCode before(async function balanceTest11() { this.timeout(30000) @@ -282,7 +298,7 @@ describe('balance #11', () => { }) // success mainnet -describe('balance #12', () => { +describeOnline('balance #12', () => { let exitCode before(async function balanceTest12() { this.timeout(15000) @@ -306,7 +322,7 @@ describe('balance #12', () => { }) // success wallet file -describe('balance #13', () => { +describeOnline('balance #13', () => { let exitCode before(async function balanceTest13() { this.timeout(20000) @@ -335,7 +351,7 @@ describe('balance #13', () => { }) // success enc-wallet file -describe('balance #14', () => { +describeOnline('balance #14', () => { let exitCode before(async function balanceTest14() { this.timeout(20000) @@ -363,4 +379,577 @@ describe('balance #14', () => { it('exit code should be 0 if passed with a valid encrypted wallet file and password flag', () => { assert.strictEqual(exitCode, 0) }) -}) \ No newline at end of file +}) +// /////////////////////////////////////////////////////////////////////////// +// balance: offline coverage suite +// +// `balance` is a read-only node query, so the part that reads a balance cannot +// run without a node. Everything *before* the query can: argument handling, +// STDIN input, address validation, wallet-file opening and decryption, the +// --json output mode's suppressed-spinner branches, and the connection-failure +// path. +// +// Every case here either stops at a validation gate or dies connecting to a +// closed loopback port. Nothing contacts mainnet or testnet. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback: resolves, refuses the connection, exits. Never leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +const BALANCE_WALLET = '/tmp/balance-wallet.json' +// Pre-v2 (`aes256` package) encryption: unauthenticated, so a wrong password +// returns garbage instead of throwing. It is the only way to reach balance.js's +// "invalid password" branch, which validates the decrypted address. +const BALANCE_LEGACY_WALLET = '/tmp/balance-wallet-legacy.json' +const BALANCE_BAD_WALLET = '/tmp/balance-bad-wallet.json' +const BALANCE_NO_FLAG_WALLET = '/tmp/balance-no-encrypted-flag-wallet.json' +const BALANCE_PASSWORD = 'testpassword' +const A_VALID_ADDRESS = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f3' + +// Run the CLI and capture what it said. `input`, when given, is written to STDIN. +function runBalance(args, input) { + return new Promise((resolve) => { + const child = spawn('./bin/run', args, {stdio: ['pipe', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', (d) => { + out += d.toString() + }) + child.stderr.on('data', (d) => { + out += d.toString() + }) + child.on('close', (code) => resolve({code, out})) + if (input !== undefined) { + child.stdin.write(input) + } + child.stdin.end() + }) +} + +function balanceRefuses(args, expected, input) { + return runBalance(args, input).then(({code, out}) => { + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}\n--- output ---\n${out}`) + assert.ok(expected.test(out), `expected output to match ${expected}\n--- actual ---\n${out}`) + }) +} + +function createBalanceWallet(file, password) { + return new Promise((resolve, reject) => { + const args = ['create-wallet', '-h', '6', '-f', file] + if (password) { + args.push('-p', password) + } + const child = spawn('./bin/run', args, {stdio: ['ignore', 'ignore', 'ignore']}) + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`create-wallet exited ${code}`)))) + child.on('error', reject) + }) +} + +// Legacy `aes256` blob: key = sha256(password), AES-256-CTR, base64(iv || ciphertext). +function legacyEncryptBalance(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(String(password)).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]) + return Buffer.concat([iv, ciphertext]).toString('base64') +} + +describe('balance: offline coverage', () => { + let plainWallet + + before(async function createBalanceFixtures() { + this.timeout(120000) + await createBalanceWallet(BALANCE_WALLET, null) + ;[plainWallet] = JSON.parse(fs.readFileSync(BALANCE_WALLET)) + + fs.writeFileSync( + BALANCE_LEGACY_WALLET, + JSON.stringify([ + { + encrypted: true, + address: legacyEncryptBalance(BALANCE_PASSWORD, plainWallet.address), + addressB32: legacyEncryptBalance(BALANCE_PASSWORD, plainWallet.addressB32), + pk: legacyEncryptBalance(BALANCE_PASSWORD, plainWallet.pk), + hexseed: legacyEncryptBalance(BALANCE_PASSWORD, plainWallet.hexseed), + mnemonic: legacyEncryptBalance(BALANCE_PASSWORD, plainWallet.mnemonic), + height: plainWallet.height, + hashFunction: plainWallet.hashFunction, + signatureType: plainWallet.signatureType, + index: plainWallet.index, + }, + ]) + ) + + // Valid JSON, but not a wallet: `JSON.parse(contents)[0]` is undefined, so + // reading `.encrypted` off it throws inside the command's try/catch. + fs.writeFileSync(BALANCE_BAD_WALLET, JSON.stringify({not: 'a wallet'})) + + // Shaped like a wallet but with no `encrypted` key: neither the plaintext nor the + // encrypted branch runs, so the file is never accepted and the command must say so + // rather than falling through and querying the filename as an address. + fs.writeFileSync(BALANCE_NO_FLAG_WALLET, JSON.stringify([{address: A_VALID_ADDRESS}])) + }) + + after(() => { + [BALANCE_WALLET, BALANCE_LEGACY_WALLET, BALANCE_BAD_WALLET, BALANCE_NO_FLAG_WALLET].forEach((file) => { + try { + fs.unlinkSync(file) + } catch (err) { + // fixture already gone; nothing to clean up + } + }) + }) + + describe('address input', () => { + it('reads the address from STDIN when the argument is "-"', async function stdinDash() { + this.timeout(60000) + // Reaches the node query with the piped address, then fails to connect. + await balanceRefuses(['balance', '-', '-g', DEAD_NODE], /Failed to connect to node/, `${A_VALID_ADDRESS}\n`) + }) + + it('reads the address from STDIN when no argument is given and STDIN is a pipe', async function stdinPipe() { + this.timeout(60000) + await balanceRefuses(['balance', '-g', DEAD_NODE], /Failed to connect to node/, `${A_VALID_ADDRESS}\n`) + }) + + it('explains what is missing when STDIN is a pipe carrying nothing', async function stdinEmpty() { + this.timeout(60000) + await balanceRefuses(['balance'], /Missing QRL address or wallet file/, '') + }) + }) + + describe('wallet files', () => { + it('reads the address out of an unencrypted wallet file', async function plainWalletFile() { + this.timeout(60000) + const {code, out} = await runBalance(['balance', BALANCE_WALLET, '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(out.includes(plainWallet.address), `expected the wallet address in the output\n--- actual ---\n${out}`) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + }) + + it('decrypts a legacy-format wallet file with the right password', async function legacyWalletFile() { + this.timeout(60000) + const {code, out} = await runBalance([ + 'balance', + BALANCE_LEGACY_WALLET, + '-p', + BALANCE_PASSWORD, + '-g', + DEAD_NODE, + ]) + assert.notStrictEqual(code, 0) + assert.ok(out.includes(plainWallet.address), `expected the decrypted address in the output\n--- actual ---\n${out}`) + }) + + it('refuses a legacy-format wallet file when the password is wrong', async function legacyWalletBadPassword() { + this.timeout(60000) + // Unauthenticated format: decryption "succeeds" and yields garbage, so the + // command has to notice the plaintext is not a QRL address. + await balanceRefuses( + ['balance', BALANCE_LEGACY_WALLET, '-p', 'not-the-password'], + /Unable to open wallet file: invalid password/ + ) + }) + + it('refuses a wallet file with no "encrypted" key', async function noEncryptedFlag() { + this.timeout(60000) + await balanceRefuses( + ['balance', BALANCE_NO_FLAG_WALLET], + /Unable to get a balance: invalid QRL address\/wallet file/ + ) + }) + + it('refuses a JSON file that is not a wallet', async function notAWallet() { + this.timeout(60000) + const {code} = await runBalance(['balance', BALANCE_BAD_WALLET]) + assert.notStrictEqual(code, 0) + }) + }) + + describe('--json output mode', () => { + it('suppresses the spinner and reports the connection failure as a log line', async function jsonConnectFailure() { + this.timeout(60000) + // With --json there is no spinner, so the failure has to be printed instead + // of being written onto a spinner that does not exist. + const {code, out} = await runBalance(['balance', A_VALID_ADDRESS, '-j', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + // The network banner is part of the human output and must not pollute JSON mode + assert.ok(!/Custom GRPC endpoint/.test(out), `--- actual ---\n${out}`) + }) + + it('does not print the wallet address banner in --json mode', async function jsonWalletFile() { + this.timeout(60000) + const {code, out} = await runBalance(['balance', BALANCE_WALLET, '-j', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + }) + }) + + describe('node connection', () => { + it('fails with a spinner message when the node is unreachable', async function deadNode() { + this.timeout(60000) + await balanceRefuses(['balance', A_VALID_ADDRESS, '-g', DEAD_NODE], /Failed to connect to node/) + }) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// balance: what the command does once a node has answered +// +// The suites above stop wherever a node would be needed. This one runs the +// command in *this* process against a stub gRPC client, so the retry loop and +// every rendering of a GetOptimizedAddressState reply - quanta, shor, JSON and +// the token list - are covered without a node, a socket, or a packet leaving +// the machine. `balance` only reads, so no OTS key is ever consumed. +// +// src/functions/grpc is swapped in the require cache for the moment it takes to +// require the command (the command captures Qrlnode at require time), then the +// real module is put straight back. +// /////////////////////////////////////////////////////////////////////////// + +// kleur colours by environment variable rather than by isTTY, so the captured +// output still carries escape sequences. Strip them before matching. +const BALANCE_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +// Behaviour the stub should show for the test currently running. Reset per test. +let balanceNode = {} +let balanceRequests = [] +let balanceConnectAttempts = 0 + +class BalanceFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + balanceConnectAttempts += 1 + if (balanceNode.connectThrows) { + throw new Error(balanceNode.connectThrows) + } + const connectsOn = balanceNode.connectsOnAttempt === undefined ? 1 : balanceNode.connectsOnAttempt + if (connectsOn !== 0 && balanceConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + balanceRequests.push({name, request}) + if (balanceNode.apiThrows) { + throw new Error(balanceNode.apiThrows) + } + return balanceNode.state + } +} + +const balanceGrpcPath = require.resolve('../../src/functions/grpc') +const balanceCommandPath = require.resolve('../../src/commands/balance') + +const balanceRealGrpcEntry = require.cache[balanceGrpcPath] +require.cache[balanceGrpcPath] = { + id: balanceGrpcPath, + filename: balanceGrpcPath, + path: require('path').dirname(balanceGrpcPath), // eslint-disable-line global-require + loaded: true, + children: [], + paths: [], + exports: BalanceFakeQrlNode, +} +const {Balance} = require('../../src/commands/balance') + +if (balanceRealGrpcEntry) { + require.cache[balanceGrpcPath] = balanceRealGrpcEntry +} else { + delete require.cache[balanceGrpcPath] +} + +// Run the command here, against the stub, capturing everything it prints. +async function runBalanceOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = (chunk) => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await Balance.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + return {code, out: chunks.join('').replace(BALANCE_ANSI, '')} +} + +// 2.5 Quanta, and two token holdings keyed by token hash. +const BALANCE_STATE = { + state: { + balance: '2500000000', + tokens: { + '000000000000000000000000000000000000000000000000000000000000beef': '1200', + '000000000000000000000000000000000000000000000000000000000000cafe': '7', + }, + }, +} + +// The address prompt and the wallet-password prompt only run when stdin and +// stdout are terminals, so through a pipe that whole branch is unreachable. Fake +// the terminal, and stand in for the two prompt libraries the command uses: +// `prompts` (required lazily inside run()) and cli-ux's `cli.prompt`. +const balanceCliUx = require('cli-ux').cli // eslint-disable-line import/order + +function stubBalancePrompts(fake) { + const promptsPath = require.resolve('prompts') + const saved = require.cache[promptsPath] + const Module = require('module') // eslint-disable-line global-require + const stub = new Module(promptsPath, null) + stub.filename = promptsPath + stub.loaded = true + stub.exports = fake + require.cache[promptsPath] = stub + return () => { + if (saved === undefined) { + delete require.cache[promptsPath] + } else { + require.cache[promptsPath] = saved + } + } +} + +// cli-ux exposes `prompt` as a getter, so it has to be redefined rather than assigned. +function stubBalancePassword(password) { + const saved = Object.getOwnPropertyDescriptor(balanceCliUx, 'prompt') + const asked = [] + Object.defineProperty(balanceCliUx, 'prompt', { + configurable: true, + get: () => async (message, options) => { + asked.push({message, options}) + return password + }, + }) + return {asked, restore: () => Object.defineProperty(balanceCliUx, 'prompt', saved)} +} + +async function runBalanceInteractive(argv, {fakePrompts, fakePassword} = {}) { + const restorePrompts = stubBalancePrompts(fakePrompts || (async () => ({}))) + const password = fakePassword === undefined ? null : stubBalancePassword(fakePassword) + const savedStdout = process.stdout.isTTY + const savedStdin = process.stdin.isTTY + process.stdout.isTTY = true + process.stdin.isTTY = true + try { + const result = await runBalanceOffline(argv) + return {...result, asked: password ? password.asked : []} + } finally { + process.stdout.isTTY = savedStdout + process.stdin.isTTY = savedStdin + if (password) { + password.restore() + } + restorePrompts() + } +} + +describe('balance: reporting a node reply', () => { + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[balanceCommandPath] + }) + + beforeEach(() => { + balanceNode = {state: {state: {balance: '2500000000'}}} + balanceRequests = [] + balanceConnectAttempts = 0 + }) + + it('asks the node for the address it was given, as bytes', async () => { + const {code} = await runBalanceOffline(['-g', DEAD_NODE, A_VALID_ADDRESS]) + assert.strictEqual(code, 0) + assert.strictEqual(balanceRequests.length, 1) + assert.strictEqual(balanceRequests[0].name, 'GetOptimizedAddressState') + assert.strictEqual(balanceRequests[0].request.address.toString('hex'), A_VALID_ADDRESS.substring(1)) + }) + + it('retries the connection until the node answers', async () => { + balanceNode = {state: {state: {balance: '0'}}, connectsOnAttempt: 3} + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(balanceConnectAttempts, 3) + assert.ok(/retry connection attempt: 0/.test(out), out) + }) + + it('retries quietly in --json mode, where there is no spinner to update', async () => { + balanceNode = {state: {state: {balance: '0'}}, connectsOnAttempt: 3} + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '-j', A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(balanceConnectAttempts, 3) + assert.ok(!/retry connection attempt/.test(out), out) + assert.strictEqual(JSON.parse(out).balance_shor, '0') + }) + + it('reports quanta by default', async () => { + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Balance: 2\.5 Quanta/.test(out), out) + assert.ok(!/Shor/.test(out), out) + }) + + it('reports shor when asked for shor', async () => { + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '-s', A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Balance: 2500000000 Shor/.test(out), out) + assert.ok(!/Quanta/.test(out), out) + }) + + it('reports quanta when asked for quanta', async () => { + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '-q', A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Balance: 2\.5 Quanta/.test(out), out) + }) + + it('refuses to report both units at once', async () => { + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '-q', '-s', A_VALID_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Please enter one, shor \(-s\) or quanta \(-q\)/.test(out), out) + }) + + it('lists token balances under their token hash', async () => { + balanceNode = {state: BALANCE_STATE} + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Token Balances:/.test(out), out) + assert.ok(out.includes('000000000000000000000000000000000000000000000000000000000000beef: 1200'), out) + assert.ok(out.includes('000000000000000000000000000000000000000000000000000000000000cafe: 7'), out) + }) + + it('says nothing about tokens when the address holds none', async () => { + const {out} = await runBalanceOffline(['-g', DEAD_NODE, A_VALID_ADDRESS]) + assert.ok(!/Token Balances:/.test(out), out) + }) + + describe('--json', () => { + it('prints one object carrying both units and the token map', async () => { + balanceNode = {state: BALANCE_STATE} + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '-j', A_VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.deepStrictEqual(JSON.parse(out), { + address: A_VALID_ADDRESS, + balance_shor: '2500000000', + balance_quanta: '2.5', + tokens: BALANCE_STATE.state.tokens, + }) + }) + + it('reports an empty token map rather than omitting the key', async () => { + const {out} = await runBalanceOffline(['-g', DEAD_NODE, '-j', A_VALID_ADDRESS]) + assert.deepStrictEqual(JSON.parse(out).tokens, {}) + }) + + it('prints no banner or spinner text alongside the JSON', async () => { + const {out} = await runBalanceOffline(['-g', DEAD_NODE, '-j', A_VALID_ADDRESS]) + assert.ok(!/Custom GRPC endpoint/.test(out), out) + assert.ok(!/Fetching balance from node/.test(out), out) + }) + + it('reports a connect() failure as a plain log line with no spinner to fail', async () => { + balanceNode = {connectThrows: 'no route to host'} + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '-j', A_VALID_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to connect to node: Error: no route to host/.test(out), out) + }) + }) + it('reports a directory where a wallet file was expected, rather than crashing', async () => { + // existsSync says yes to a directory, so the command goes on to read it as a wallet. + // That read throws, and it used to escape as a raw EISDIR. + const {code, out} = await runBalanceOffline(['-g', DEAD_NODE, '/tmp']) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to get a balance: invalid QRL address\/wallet file/.test(out), out) + assert.ok(!/EISDIR/.test(out), out) + assert.strictEqual(balanceRequests.length, 0, 'no node is queried for a path that never opened') + }) + + describe('at a terminal', () => { + const promptWallet = '/tmp/balance-node-prompt-wallet.json' + + before(() => { + // Decrypts with the right password to a real address, so the command gets + // past the address check and on to the (stubbed) node. + const aes = require('../../src/utils/aes') // eslint-disable-line global-require + fs.writeFileSync( + promptWallet, + JSON.stringify([{encrypted: true, address: aes.encrypt('prompted-password', A_VALID_ADDRESS)}]) + ) + }) + + after(() => { + try { + fs.unlinkSync(promptWallet) + } catch (err) { + // never created; nothing to clean up + } + }) + + it('asks for an address when none was given', async () => { + let asked + const {code, out} = await runBalanceInteractive(['-g', DEAD_NODE], { + fakePrompts: async options => { + asked = options + return {address: A_VALID_ADDRESS} + }, + }) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.name, 'address') + assert.ok(/QRL address or path to wallet\.json/.test(asked.message)) + assert.ok(/Balance: 2\.5 Quanta/.test(out), out) + }) + + it('will not accept an empty answer at the address prompt', async () => { + let asked + await runBalanceInteractive(['-g', DEAD_NODE], { + fakePrompts: async options => { + asked = options + return {address: A_VALID_ADDRESS} + }, + }) + assert.strictEqual(asked.validate(''), 'Address/File is required') + assert.strictEqual(asked.validate(A_VALID_ADDRESS), true) + }) + + it('exits non-zero when the address prompt is cancelled', async () => { + const {code, out} = await runBalanceInteractive(['-g', DEAD_NODE], {fakePrompts: async () => ({})}) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.strictEqual(balanceRequests.length, 0, 'no node is queried without an address') + }) + + it('asks for the wallet password when --password is not given', async () => { + // Without this branch an encrypted wallet would only be usable with the + // password on the command line, where it lands in shell history. + const {code, out, asked} = await runBalanceInteractive([promptWallet, '-g', DEAD_NODE], { + fakePassword: 'prompted-password', + }) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.length, 1) + assert.ok(/Enter password for wallet file/.test(asked[0].message)) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + assert.ok(out.includes(A_VALID_ADDRESS), out) + }) + + it('refuses a wrong password typed at the wallet prompt', async () => { + const {code, out} = await runBalanceInteractive([promptWallet, '-g', DEAD_NODE], { + fakePassword: 'not-the-password', + }) + assert.strictEqual(code, 1, out) + assert.strictEqual(balanceRequests.length, 0, 'no node is queried for an address that never decrypted') + }) + }) +}) diff --git a/test/commands/config.test.js b/test/commands/config.test.js index 6af5cff..77ce17e 100644 --- a/test/commands/config.test.js +++ b/test/commands/config.test.js @@ -1,49 +1,227 @@ +// /////////////////////////////////////////////////////////////////////////// +// config command tests +// +// `qrl-cli config` is entirely local: it reads and writes a `conf` store on +// disk. That store is the first layer of the endpoint-precedence chain used by +// every command that talks to a node, so a wrong value here silently redirects +// the whole CLI -- which is why each case below asserts on the message the user +// is shown, not only on the exit code. +// +// Every child process is spawned with the config directory redirected to a +// throwaway temp dir. Without that these tests would write to (and delete keys +// from) the real qrl-cli config of whoever runs the suite. `env-paths`, which +// `conf` uses, resolves that directory differently per platform, so all four +// candidate variables are set rather than assuming the runner's OS. +// +// Nothing here contacts a node. +// /////////////////////////////////////////////////////////////////////////// + const assert = require('assert') -const { spawn } = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') +const {spawn} = require('child_process') + +let tempHome +let childEnv -const processFlags = { - detached: true, - stdio: ['ignore', 'inherit', 'inherit'], +// kleur still colours its output when stdout is a pipe, so strip the escapes +// before matching on the message text. +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +// Run the CLI against the throwaway config store and capture what it said. +function run(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe'], env: childEnv}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out: out.replace(ANSI, '')})) + }) } describe('config command tests', () => { - it('exit code should be 0 when listing config', (done) => { - const process = spawn('./bin/run', ['config', 'list'], processFlags) - process.on('exit', (code) => { - assert.strictEqual(code, 0) - done() - }) + before(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-config-test-')) + childEnv = { + ...process.env, + HOME: tempHome, + XDG_CONFIG_HOME: tempHome, + APPDATA: tempHome, + LOCALAPPDATA: tempHome, + } }) - it('exit code should be 0 when setting a config key', (done) => { - const process = spawn('./bin/run', ['config', 'set', 'default-network', 'testnet'], processFlags) - process.on('exit', (code) => { - assert.strictEqual(code, 0) - done() - }) + after(() => { + fs.rmSync(tempHome, {recursive: true, force: true}) }) - it('exit code should be 0 when getting a config key', (done) => { - const process = spawn('./bin/run', ['config', 'get', 'default-network'], processFlags) - process.on('exit', (code) => { - assert.strictEqual(code, 0) - done() - }) + // ------------------------------------------------------------------ + // The original cases, kept intact, now with the message asserted too. + // ------------------------------------------------------------------ + + it('exit code should be 0 when listing config', async () => { + // Runs first, against a store that has never been written: the empty branch. + const {code, out} = await run(['config', 'list']) + assert.strictEqual(code, 0) + assert.ok(/No configuration values set/.test(out), out) }) - it('exit code should be 0 when deleting a config key', (done) => { - const process = spawn('./bin/run', ['config', 'delete', 'default-network'], processFlags) - process.on('exit', (code) => { - assert.strictEqual(code, 0) - done() - }) + it('exit code should be 0 when setting a config key', async () => { + const {code, out} = await run(['config', 'set', 'default-network', 'testnet']) + assert.strictEqual(code, 0) + assert.ok(/set to testnet/.test(out), out) }) - it('exit code should be 1 when setting an invalid network key', (done) => { - const process = spawn('./bin/run', ['config', 'set', 'default-network', 'invalidnet'], processFlags) - process.on('exit', (code) => { - assert.strictEqual(code, 1) - done() - }) + it('exit code should be 0 when getting a config key', async () => { + const {code, out} = await run(['config', 'get', 'default-network']) + assert.strictEqual(code, 0) + assert.strictEqual(out.trim(), 'testnet') + }) + + it('exit code should be 0 when deleting a config key', async () => { + const {code, out} = await run(['config', 'delete', 'default-network']) + assert.strictEqual(code, 0) + assert.ok(/deleted/.test(out), out) + }) + + it('exit code should be 1 when setting an invalid network key', async () => { + const {code, out} = await run(['config', 'set', 'default-network', 'invalidnet']) + assert.strictEqual(code, 1) + assert.ok(/Invalid value for default-network/.test(out), out) + }) + + // ------------------------------------------------------------------ + // The store really is isolated. If this fails, every case above has + // been mutating the developer's own configuration. + // ------------------------------------------------------------------ + + it('writes only to the redirected config directory', async () => { + await run(['config', 'set', 'grpc-endpoint', '127.0.0.1:19009']) + const stored = path.join(tempHome, 'qrl-cli-nodejs', 'config.json') + assert.ok(fs.existsSync(stored), `expected a config file at ${stored}`) + assert.match(fs.readFileSync(stored, 'utf8'), /127\.0\.0\.1:19009/) + await run(['config', 'delete', 'grpc-endpoint']) + }) + + // ------------------------------------------------------------------ + // No action: the usage banner. + // ------------------------------------------------------------------ + + it('prints usage and exits 0 when given no action', async () => { + const {code, out} = await run(['config']) + assert.strictEqual(code, 0) + assert.ok(/Usage: qrl-cli config \[get\|set\|list\|delete\]/.test(out), out) + // The banner has to name the keys it accepts, or `list` is the only way to + // discover them. + assert.ok(/default-network/.test(out), out) + assert.ok(/grpc-endpoint/.test(out), out) + }) + + // ------------------------------------------------------------------ + // list + // ------------------------------------------------------------------ + + it('lists every stored key once values exist', async () => { + await run(['config', 'set', 'default-network', 'mainnet']) + await run(['config', 'set', 'grpc-endpoint', '127.0.0.1:19009']) + const {code, out} = await run(['config', 'list']) + assert.strictEqual(code, 0) + assert.ok(/default-network: mainnet/.test(out), out) + assert.ok(/grpc-endpoint: 127\.0\.0\.1:19009/.test(out), out) + await run(['config', 'delete', 'default-network']) + await run(['config', 'delete', 'grpc-endpoint']) + }) + + // ------------------------------------------------------------------ + // get + // ------------------------------------------------------------------ + + it('refuses `get` with no key', async () => { + const {code, out} = await run(['config', 'get']) + assert.strictEqual(code, 1) + assert.ok(/Missing key\. Usage: qrl-cli config get /.test(out), out) + }) + + it('reports an unset key as unset rather than as empty', async () => { + // Distinguishing "not set" from "set to nothing" matters: the caller uses + // this to decide whether to fall back to the built-in default endpoint. + const {code, out} = await run(['config', 'get', 'never-set-by-any-test']) + assert.strictEqual(code, 0) + assert.ok(/is not set/.test(out), out) + }) + + // ------------------------------------------------------------------ + // set + // ------------------------------------------------------------------ + + it('refuses `set` with no key and no value', async () => { + const {code, out} = await run(['config', 'set']) + assert.strictEqual(code, 1) + assert.ok(/Missing key or value/.test(out), out) + }) + + it('refuses `set` with a key but no value', async () => { + const {code, out} = await run(['config', 'set', 'grpc-endpoint']) + assert.strictEqual(code, 1) + assert.ok(/Missing key or value/.test(out), out) + }) + + it('accepts mainnet for default-network', async () => { + const {code, out} = await run(['config', 'set', 'default-network', 'mainnet']) + assert.strictEqual(code, 0) + assert.ok(/set to mainnet/.test(out), out) + await run(['config', 'delete', 'default-network']) + }) + + it('only validates the value of default-network, not of other keys', async () => { + // Pinning current behaviour: an arbitrary key/value pair is stored without + // checking. Worth knowing, because `grpc-endpoint` is one such key and it + // decides which host the CLI speaks plaintext gRPC to. + const {code, out} = await run(['config', 'set', 'grpc-endpoint', 'not-a-real-endpoint']) + assert.strictEqual(code, 0) + assert.ok(/set to not-a-real-endpoint/.test(out), out) + const got = await run(['config', 'get', 'grpc-endpoint']) + assert.strictEqual(got.out.trim(), 'not-a-real-endpoint') + await run(['config', 'delete', 'grpc-endpoint']) + }) + + // ------------------------------------------------------------------ + // delete / remove + // ------------------------------------------------------------------ + + it('refuses `delete` with no key', async () => { + const {code, out} = await run(['config', 'delete']) + assert.strictEqual(code, 1) + assert.ok(/Missing key\. Usage: qrl-cli config delete /.test(out), out) + }) + + it('accepts `remove` as an alias for `delete`', async () => { + await run(['config', 'set', 'default-network', 'testnet']) + const {code, out} = await run(['config', 'remove', 'default-network']) + assert.strictEqual(code, 0) + assert.ok(/deleted/.test(out), out) + const got = await run(['config', 'get', 'default-network']) + assert.ok(/is not set/.test(got.out), got.out) + }) + + it('reports success deleting a key that was never set', async () => { + const {code, out} = await run(['config', 'delete', 'never-set-by-any-test']) + assert.strictEqual(code, 0) + assert.ok(/deleted/.test(out), out) + }) + + // ------------------------------------------------------------------ + // Anything else + // ------------------------------------------------------------------ + + it('refuses an unknown action', async () => { + const {code, out} = await run(['config', 'bogus']) + assert.strictEqual(code, 1) + assert.ok(/Unknown action: bogus/.test(out), out) }) }) diff --git a/test/commands/dump-transactions.test.js b/test/commands/dump-transactions.test.js index 4f469e7..9f26af7 100644 --- a/test/commands/dump-transactions.test.js +++ b/test/commands/dump-transactions.test.js @@ -1,78 +1,521 @@ +// /////////////////////////////////////////////////////////////////////////// +// dump-transactions tests +// +// Two layers, both offline: +// +// 1. Child-process runs of ./bin/run for everything that happens before a node +// is needed - argument parsing, address validation, wallet file handling and +// password decryption - plus the connection failure path, which is pointed at +// a closed loopback port so it never leaves the machine. +// +// 2. In-process runs for the half of the command that only executes once a node +// has answered: the retry loop, pagination, the console table, and the CSV +// writer. src/functions/grpc is swapped for a plain stub class for the single +// moment it takes to require the command (see below) - there is no server and +// no socket, and the real module is restored immediately, so nothing here +// leaks into other test files. +// +// No transaction is ever built or signed, so no OTS key is consumed. +// /////////////////////////////////////////////////////////////////////////// + /* eslint-env mocha */ /* eslint max-nested-callbacks: ["error", 10] */ -/* eslint no-console: 0 */ const assert = require('assert') -const { DumpTransactions } = require('../../src/commands/dump-transactions') +const {spawn} = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') -describe('commands/dump-transactions', () => { - it('should fail without address parameter', async () => { - let exited = false - try { - await DumpTransactions.run([]) - } catch (error) { // eslint-disable-line no-unused-vars - exited = true - // Expected to exit due to missing required parameter +const aes = require('../../src/utils/aes') + +// A closed port on loopback: the CLI resolves it, fails to connect, and exits. +const DEAD_NODE = '127.0.0.1:1' + +// --------------------------------------------------------------------------- +// gRPC stub +// --------------------------------------------------------------------------- + +// Behaviour the stub should show for the test currently running. Reset per test. +let nodeBehaviour = {} + +// Requests the command sent, so the tests can assert on what it asked the node +// for (page numbers, page size, and the address it converted to bytes). +let apiRequests = [] + +// How many times the command asked to connect: the retry loop only updates the +// spinner text, which prints nothing when stderr is not a terminal. +let connectAttempts = 0 + +class FakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + connectAttempts += 1 + if (nodeBehaviour.connectThrows) { + throw new Error(nodeBehaviour.connectThrows) } - assert(exited, 'Command should exit when no address is provided') - }) + // connectsOnAttempt of 0 means "never connects", which is what the real + // client does when the node answers but fails the proto hash check. + const connectsOn = nodeBehaviour.connectsOnAttempt === undefined ? 1 : nodeBehaviour.connectsOnAttempt + if (connectsOn !== 0 && connectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } - it('should fail with invalid address', async () => { - let exited = false - try { - await DumpTransactions.run(['invalid-address']) - } catch (error) { // eslint-disable-line no-unused-vars - exited = true - // Expected to exit due to invalid address + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + apiRequests.push({name, request}) + if (nodeBehaviour.apiThrows) { + throw new Error(nodeBehaviour.apiThrows) } - assert(exited, 'Command should exit when invalid address is provided') + const page = nodeBehaviour.pages[request.page_number - 1] || [] + return {transactions_detail: page} + } +} + +const grpcModulePath = require.resolve('../../src/functions/grpc') +const commandModulePath = require.resolve('../../src/commands/dump-transactions') + +// The command captures Qrlnode at require time, so the stub only has to be in +// place for that one require call. Put the real entry back straight afterwards +// so a full-suite run is unaffected. +const realGrpcEntry = require.cache[grpcModulePath] +require.cache[grpcModulePath] = { + id: grpcModulePath, + filename: grpcModulePath, + path: path.dirname(grpcModulePath), + loaded: true, + children: [], + paths: [], + exports: FakeQrlNode, +} +const {DumpTransactions} = require('../../src/commands/dump-transactions') + +if (realGrpcEntry) { + require.cache[grpcModulePath] = realGrpcEntry +} else { + delete require.cache[grpcModulePath] +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Run the CLI in a child process and capture what it said, rather than letting +// it write to the test output. QRL_GRPC_ENDPOINT keeps the default (no flags) +// network selection on loopback instead of mainnet. +function run(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: {...process.env, QRL_TEST_OFFLINE: 'true', QRL_GRPC_ENDPOINT: DEAD_NODE}, + }) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) }) +} + +// Every failing case asserts on the reason, not just the exit code, so a command +// that starts failing for a different reason does not keep passing. +async function refuses(args, expected) { + const {code, out} = await run(args) + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}`) + assert.ok(expected.test(out), `expected output to match ${expected}\n--- actual ---\n${out}`) +} + +// Run the command in this process against the stub, capturing everything it +// prints (this.log goes to stdout, the ora spinners go to stderr). +async function runOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = chunk => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await DumpTransactions.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + return {code, out: chunks.join('')} +} + +const hexBuffer = hex => Buffer.from(hex, 'hex') + +const FROM_HEX = '000300cc040d28c309c8e82d1397aa0d9b74666b492f77b485d327bf5496a725b7b8a3c024b9ee' +const TO_HEX = '0002003b4c8bb2c0e1a1b0b9b9f0e0e8b2e9d0c1b2a3948576f8e9d0c1b2a3948576f8e9d0c1b2a3' - it('should accept valid QRL address format', () => { - // This is a basic format test - we're not testing actual network calls - // A real address (test/test-wallet/wallet.json). The previous literal was 77 characters, - // so this assertion could never have passed. - const validAddress = 'Q000300cc040d28c309c8e82d1397aa0d9b74666b492f77b485d327bf5496a725b7b8a3c024b9ee' - - // Test that the address is in the correct format (starts with Q and is 79 chars) - assert(validAddress.length === 79, 'Valid QRL address should be 79 characters') - assert(validAddress.startsWith('Q'), 'Valid QRL address should start with Q') +// A GetTransactionsByAddress entry, shaped the way the node returns it. +const transaction = (body, extra = {}) => ({ + addr_from: hexBuffer(FROM_HEX), + header: {timestamp_seconds: 1600000000, block_number: 12345, ...extra}, + tx: {transaction_hash: hexBuffer('ab'.repeat(32)), fee: '100000000', ...body}, +}) + +const TRANSFER_TX = transaction({ + transactionType: 'transfer', + transfer: {amounts: ['1500000000'], addrs_to: [hexBuffer(TO_HEX)]}, +}) + +const COINBASE_TX = transaction({ + transactionType: 'coinbase', + coinbase: {amount: '2000000000', addr_to: hexBuffer(TO_HEX)}, +}) + +// The comma in the type exercises the CSV quoting path. +const TOKEN_TX = transaction({ + transactionType: 'transfer_token,v2', + transfer_token: {amounts: ['42'], addrs_to: [hexBuffer(TO_HEX)]}, +}) + +// No transfer/coinbase/token body and no type: the command has to fall back to +// "unknown", amount 0 and to N/A rather than throwing. +const BARE_TX = transaction({}) + +let tmpDir +let plainWallet +let encryptedWallet +let notJsonWallet +let noEncryptedFlagWallet +let decryptsToGarbageWallet +let plainAddress +const WALLET_PASSWORD = 'testing' + +describe('commands/dump-transactions', () => { + before(async function beforeAll() { + this.timeout(180000) + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-dump-')) + plainWallet = path.join(tmpDir, 'wallet.json') + encryptedWallet = path.join(tmpDir, 'enc-wallet.json') + notJsonWallet = path.join(tmpDir, 'not-json.json') + noEncryptedFlagWallet = path.join(tmpDir, 'no-encrypted-flag.json') + decryptsToGarbageWallet = path.join(tmpDir, 'decrypts-to-garbage.json') + + const created = await run(['create-wallet', '-3', '-h', '6', '-f', plainWallet]) + assert.strictEqual(created.code, 0, `create-wallet failed: ${created.out}`) + const encCreated = await run(['create-wallet', '-3', '-h', '6', '-f', encryptedWallet, '-p', WALLET_PASSWORD]) + assert.strictEqual(encCreated.code, 0, `encrypted create-wallet failed: ${encCreated.out}`) + + plainAddress = JSON.parse(fs.readFileSync(plainWallet))[0].address + + fs.writeFileSync(notJsonWallet, 'this is not a wallet') + // encrypted is neither true nor false, so neither decryption branch runs and + // the file has to be rejected rather than silently used. + fs.writeFileSync(noEncryptedFlagWallet, JSON.stringify([{address: plainAddress}])) + // Decrypts cleanly with the right password but does not yield an address: + // the only way to reach the "invalid password" check without an error. + fs.writeFileSync( + decryptsToGarbageWallet, + JSON.stringify([{encrypted: true, address: aes.encrypt(WALLET_PASSWORD, 'not-an-address')}]) + ) }) - it('should have correct command description', () => { - assert(typeof DumpTransactions.description === 'string') - assert(DumpTransactions.description.includes('transaction list')) - assert(DumpTransactions.description.includes('CSV')) + after(() => { + // The command in the require cache holds the stubbed Qrlnode; drop it so a + // later require in the same process gets the real one back. + delete require.cache[commandModulePath] + fs.rmSync(tmpDir, {recursive: true, force: true}) }) - it('should have required args defined', () => { - assert(Array.isArray(DumpTransactions.args)) - assert(DumpTransactions.args.length === 1) - assert(DumpTransactions.args[0].name === 'address') - assert(DumpTransactions.args[0].required === true) + beforeEach(() => { + nodeBehaviour = {pages: [[]]} + apiRequests = [] + connectAttempts = 0 }) - it('should have expected flags', () => { - const flagNames = Object.keys(DumpTransactions.flags) - const expectedFlags = ['testnet', 'mainnet', 'grpc', 'password', 'csv', 'limit', 'quiet'] - - expectedFlags.forEach(flag => { - assert(flagNames.includes(flag), `Should have ${flag} flag`) + describe('command definition', () => { + it('takes a single required address argument', () => { + assert.ok(Array.isArray(DumpTransactions.args)) + assert.strictEqual(DumpTransactions.args.length, 1) + assert.strictEqual(DumpTransactions.args[0].name, 'address') + assert.strictEqual(DumpTransactions.args[0].required, true) + }) + + it('documents itself as a transaction dump with CSV export', () => { + assert.ok(DumpTransactions.description.includes('transaction list')) + assert.ok(DumpTransactions.description.includes('CSV')) + }) + + it('offers the network, wallet and output flags', () => { + const names = Object.keys(DumpTransactions.flags) + ;['testnet', 'mainnet', 'grpc', 'password', 'csv', 'limit', 'quiet'].forEach(flag => { + assert.ok(names.includes(flag), `should have a ${flag} flag`) + }) + assert.strictEqual(DumpTransactions.flags.limit.default, 100) + assert.strictEqual(DumpTransactions.flags.testnet.default, false) + assert.strictEqual(DumpTransactions.flags.mainnet.default, false) }) }) - it('should have csv flag with string type', () => { - const csvFlag = DumpTransactions.flags.csv - assert(csvFlag.required === false, 'CSV flag should be optional') + describe('address and wallet file validation', () => { + it('exits non-zero with no arguments', async () => { + await refuses(['dump-transactions'], /Missing 1 required arg/) + }) + + it('rejects a string that is neither an address nor a file', async () => { + await refuses(['dump-transactions', 'not-an-address'], /invalid QRL address\/wallet file/) + }) + + it('rejects a wallet path that does not exist', async () => { + await refuses( + ['dump-transactions', path.join(tmpDir, 'nope.json')], + /invalid QRL address\/wallet file/ + ) + }) + + it('reports the parse failure for a file that is not JSON', async () => { + await refuses(['dump-transactions', notJsonWallet], /JSON/) + }) + + it('refuses a directory where a wallet file was expected, rather than crashing', async () => { + // existsSync says yes to a directory, so the command goes on to read it as a + // wallet. That read throws, and it used to escape as a raw EISDIR. + const {code, out} = await run(['dump-transactions', '/tmp']) + assert.strictEqual(code, 1, out) + assert.ok(/invalid QRL address\/wallet file/.test(out), out) + }) + + it('rejects a wallet file with no encrypted flag', async () => { + await refuses(['dump-transactions', noEncryptedFlagWallet], /invalid QRL address\/wallet file/) + }) + + it('reads the address out of a plaintext wallet file', async () => { + const {code, out} = await run(['dump-transactions', plainWallet, '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.ok(out.includes(plainAddress), `expected the wallet address in:\n${out}`) + assert.ok(/Custom GRPC endpoint: \[127\.0\.0\.1:1\]/.test(out), out) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('decrypts an encrypted wallet file with --password', async () => { + const {code, out} = await run([ + 'dump-transactions', + encryptedWallet, + '-p', + WALLET_PASSWORD, + '-g', + DEAD_NODE, + ]) + assert.strictEqual(code, 1, out) + assert.ok(/Address:.*Q[0-9a-f]{78}/.test(out), `expected a decrypted address in:\n${out}`) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('refuses an encrypted wallet with the wrong password', async () => { + await refuses( + ['dump-transactions', encryptedWallet, '-p', 'wrong-password', '-g', DEAD_NODE], + /Error decrypting wallet/ + ) + }) + + it('refuses a wallet whose plaintext is not an address', async () => { + await refuses( + ['dump-transactions', decryptsToGarbageWallet, '-p', WALLET_PASSWORD, '-g', DEAD_NODE], + /invalid password/ + ) + }) + + it('prompts for the password when --password is omitted', async () => { + // cli.prompt reads the terminal, which never answers under a test runner, + // so stand in for it. The command holds the cli-ux singleton, and prompt is + // an accessor there, so it has to be redefined rather than assigned. + const {cli} = require('cli-ux') // eslint-disable-line global-require + const realPrompt = Object.getOwnPropertyDescriptor(cli, 'prompt') + const asked = [] + Object.defineProperty(cli, 'prompt', { + configurable: true, + value: async question => { + asked.push(question) + return WALLET_PASSWORD + }, + }) + try { + nodeBehaviour = {pages: [[]]} + const {code, out} = await runOffline([encryptedWallet]) + assert.deepStrictEqual(asked, ['Enter password for wallet file']) + assert.strictEqual(code, 0, out) + assert.ok(/No transactions found for address Q[0-9a-f]{78}/.test(out), out) + } finally { + Object.defineProperty(cli, 'prompt', realPrompt) + } + }) }) - it('should have limit flag with integer type and default value', () => { - const limitFlag = DumpTransactions.flags.limit - assert(limitFlag.default === 100, 'Limit flag should default to 100') + describe('network selection', () => { + it('defaults to mainnet when no network flag is given', async () => { + // QRL_GRPC_ENDPOINT keeps the actual socket on loopback while the command + // still walks its default-network branch. + const {code, out} = await run(['dump-transactions', plainAddress]) + assert.strictEqual(code, 1, out) + assert.ok(/Mainnet/.test(out), out) + assert.ok(/Failed to connect to node|Failed to establish connection/.test(out), out) + }) + + it('announces testnet with --testnet', async () => { + const {out} = await runOffline(['--testnet', plainAddress]) + assert.ok(/Testnet/.test(out), out) + }) + + it('announces mainnet with --mainnet, which wins over --grpc', async () => { + const {out} = await runOffline(['--grpc', DEAD_NODE, '--mainnet', plainAddress]) + assert.ok(/Mainnet/.test(out), out) + assert.ok(!/Custom GRPC endpoint/.test(out), out) + }) }) - it('should have network flags (testnet/mainnet)', () => { - assert(DumpTransactions.flags.testnet.default === false) - assert(DumpTransactions.flags.mainnet.default === false) + describe('connecting', () => { + it('retries the connection before giving up', async () => { + nodeBehaviour = {connectsOnAttempt: 3, pages: [[]]} + const {code, out} = await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(code, 0, out) + assert.strictEqual(connectAttempts, 3, 'should have retried until the node answered') + assert.ok(/Connected to node/.test(out), out) + }) + + it('gives up when the node never completes the handshake', async () => { + nodeBehaviour = {connectsOnAttempt: 0, pages: [[]]} + const {code, out} = await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to establish connection to node/.test(out), out) + }) + + it('reports the underlying error when connect throws', async () => { + nodeBehaviour = {connectThrows: 'no route to host', pages: [[]]} + const {code, out} = await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to connect to node.*\n?.*no route to host/.test(out), out) + }) + }) + + describe('fetching', () => { + it('asks for the address as raw bytes, without the leading Q', async () => { + await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(apiRequests.length, 1) + assert.strictEqual(apiRequests[0].name, 'GetTransactionsByAddress') + assert.strictEqual( + apiRequests[0].request.address.toString('hex'), + plainAddress.substring(1).toLowerCase() + ) + assert.strictEqual(apiRequests[0].request.page_number, 1) + }) + + it('says so when the address has no transactions', async () => { + const {code, out} = await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes(`No transactions found for address ${plainAddress}`), out) + assert.ok(!/Transaction Summary/.test(out), 'should not print an empty table') + }) + + it('exits when the node errors on a page', async () => { + nodeBehaviour = {apiThrows: 'stream removed', pages: []} + const {code, out} = await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to fetch page 1: stream removed/.test(out), out) + }) + + it('pages until a short page, pausing between pages', async function paging() { + // The command sleeps 5s between pages on purpose (API rate limit), so this + // case is deliberately slow rather than flaky. + this.timeout(60000) + nodeBehaviour = {pages: [[TRANSFER_TX], []]} + const {code, out} = await runOffline(['-g', DEAD_NODE, '-l', '1', plainAddress]) + assert.strictEqual(code, 0, out) + assert.deepStrictEqual( + apiRequests.map(r => r.request.page_number), + [1, 2] + ) + assert.strictEqual(apiRequests[0].request.item_per_page, 1) + assert.ok(/Pausing 5 seconds/.test(out), out) + assert.ok(/end of data/.test(out), out) + assert.ok(/Total transactions fetched: 1/.test(out), out) + }) + + it('falls back to 100 per page when --limit is zero', async () => { + await runOffline(['-g', DEAD_NODE, '-l', '0', plainAddress]) + assert.strictEqual(apiRequests[0].request.item_per_page, 100) + }) + }) + + describe('output', () => { + it('prints a row per transaction, whatever the transaction type', async () => { + nodeBehaviour = {pages: [[TRANSFER_TX, COINBASE_TX, TOKEN_TX, BARE_TX]]} + const {code, out} = await runOffline(['-g', DEAD_NODE, plainAddress]) + assert.strictEqual(code, 0, out) + assert.ok(/Total transactions fetched: 4/.test(out), out) + assert.ok(/Transaction Summary/.test(out), out) + assert.ok(out.includes('2020-09-13'), `expected the formatted timestamp in:\n${out}`) + assert.ok(out.includes('1.500000000'), 'transfer amount in quanta') + assert.ok(out.includes('2.000000000'), 'coinbase amount in quanta') + assert.ok(out.includes('42 tokens'), 'token amounts are not quanta') + assert.ok(out.includes('unknown'), 'a transaction with no type is reported as unknown') + assert.ok(out.includes('0.100000000'), 'fee in quanta') + assert.ok(out.includes('ab'.repeat(32)), 'transaction hash') + }) + + it('writes a CSV file with --csv, quoting fields containing commas', async () => { + const csvPath = path.join(tmpDir, 'out.csv') + nodeBehaviour = {pages: [[TRANSFER_TX, COINBASE_TX, TOKEN_TX, BARE_TX]]} + const {code, out} = await runOffline(['-g', DEAD_NODE, '-c', csvPath, plainAddress]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes(`exported to CSV file: ${csvPath}`), out) + + const rows = fs.readFileSync(csvPath, 'utf8').split('\n') + assert.strictEqual(rows[0], 'Timestamp,Type,Hash,From,To,Amount,Fee,Block') + assert.strictEqual(rows.length, 5, 'header plus one row per transaction') + assert.ok(rows[1].startsWith('2020-09-13'), rows[1]) + assert.ok(rows[1].includes(`,transfer,`), rows[1]) + assert.ok(rows[1].includes(`,Q${FROM_HEX},Q${TO_HEX},1.500000000,0.100000000,12345`), rows[1]) + assert.ok(rows[2].includes(`,coinbase,`), rows[2]) + assert.ok(rows[2].includes(',2.000000000,'), rows[2]) + // The comma inside the type has to be quoted or the columns shift. + assert.ok(rows[3].includes('"transfer_token,v2"'), rows[3]) + assert.ok(rows[3].includes(',42 tokens,'), rows[3]) + assert.ok(rows[4].includes(',unknown,'), rows[4]) + assert.ok(rows[4].includes(',N/A,0,'), 'no recipient and no amount') + // Without --quiet the table is still printed alongside the export. + assert.ok(/Transaction Summary/.test(out), out) + }) + + it('suppresses the table with --csv --quiet', async () => { + const csvPath = path.join(tmpDir, 'quiet.csv') + nodeBehaviour = {pages: [[TRANSFER_TX]]} + const {code, out} = await runOffline(['-g', DEAD_NODE, '-c', csvPath, '-q', plainAddress]) + assert.strictEqual(code, 0, out) + assert.ok(!/Transaction Summary/.test(out), out) + assert.ok(fs.existsSync(csvPath), 'the CSV is still written') + }) + + it('still prints the table with --quiet alone', async () => { + nodeBehaviour = {pages: [[TRANSFER_TX]]} + const {out} = await runOffline(['-g', DEAD_NODE, '-q', plainAddress]) + assert.ok(/Transaction Summary/.test(out), out) + }) + + it('exits when the CSV file cannot be written', async () => { + nodeBehaviour = {pages: [[TRANSFER_TX]]} + // A directory is never a valid destination, so the write always fails here. + const {code, out} = await runOffline(['-g', DEAD_NODE, '-c', tmpDir, plainAddress]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to write CSV file/.test(out), out) + }) }) -}) \ No newline at end of file +}) diff --git a/test/commands/generate-lattice-keys.test.js b/test/commands/generate-lattice-keys.test.js index 413e2b1..cfb7b84 100644 --- a/test/commands/generate-lattice-keys.test.js +++ b/test/commands/generate-lattice-keys.test.js @@ -6,7 +6,10 @@ const assert = require('assert') const {spawn} = require('child_process') const fs = require('fs'); +const path = require('path') + const setup = require('../test_setup') +const aes = require('../../src/utils/aes') const openFile = (path) => { const contents = fs.readFileSync(path) @@ -125,8 +128,12 @@ describe('generate-lattice-keys #3 - incorrect seed length', () => { }) // incorrect seed char +// The right length but not hex, so it clears the length check and only fails inside +// QRLLIB. That throw used to escape uncaught: the command exited non-zero having printed +// nothing at all, which this case could not tell apart from a clean rejection. describe('generate-lattice-keys #3.a - incorrect seed char', () => { let exitCode + let out = '' before(done => { const args = [ 'generate-lattice-keys', @@ -134,8 +141,14 @@ describe('generate-lattice-keys #3.a - incorrect seed char', () => { '-s', '020200cb68ca52ae4aff1d2ac10a2cc03f2325b95ab4610d2c6fd2af684aa1427766ac0b96b05942734d254fb9dba5fcb139HG', '-t', ] - const process = spawn('./bin/run', args, processFlags) - process.on('exit', code => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('exit', code => { exitCode = code done() }) @@ -143,6 +156,9 @@ describe('generate-lattice-keys #3.a - incorrect seed char', () => { it('exit code should be non-0 if passed with -s and incorrect hexseed', () => { assert.notStrictEqual(exitCode, 0) }) + it('says why, rather than failing silently', () => { + assert.ok(/Failed to recreate XMSS wallet object/.test(out), `expected a reason\n--- actual ---\n${out}`) + }) }) // no seed @@ -618,4 +634,564 @@ describe('generate-lattice-keys #20', () => { it('exit code should be 0 with keys broadcast to network and saved into temp file location', () => { assert.strictEqual(exitCode, 0) }) -}) \ No newline at end of file +}) +// /////////////////////////////////////////////////////////////////////////// +// Offline cases +// +// Everything below runs without a node: each case either stops at a validation +// gate or at a connection to a closed local port. The suites above that pass +// -b against testnet are the only ones that spend an OTS key; these spend none, +// and they assert on the message so a command that starts failing for a +// different reason cannot keep passing. +// +// The wallet is generated here rather than borrowed from the shared fixtures, +// so these cases stand on their own. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback: the CLI resolves it, fails to connect, and exits. +// Deterministic, and it never leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +const offlineWallet = '/tmp/glk-offline-wallet.json' +const offlineEncWallet = '/tmp/glk-offline-wallet-enc.json' +const offlineWalletPassword = 'testpassword' +const offlineKeyFile = '/tmp/glk-offline-lattice.json' + +function runLattice(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', ['generate-lattice-keys', ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +function makeWallet(args) { + return new Promise((resolve, reject) => { + const child = spawn('./bin/run', ['create-wallet', '-h', '4', ...args], processFlags) + child.on('exit', code => { + if (code === 0) { + resolve() + } else { + reject(new Error(`create-wallet exited ${code}`)) + } + }) + }) +} + +describe('generate-lattice-keys offline', () => { + let wallet + + before(async function makeWallets() { + this.timeout(120000) + await makeWallet(['-f', offlineWallet]) + await makeWallet(['-f', offlineEncWallet, '-p', offlineWalletPassword]) + const [first] = openFile(offlineWallet) + wallet = first + }) + + after(() => { + [offlineWallet, offlineEncWallet, offlineKeyFile].forEach(file => { + try { + fs.unlinkSync(file) + } catch (error) { + // never created; nothing to clean up + } + }) + }) + + it('refuses to run with neither a wallet file nor a hexseed', async () => { + const {code, out} = await runLattice(['-i', '0']) + assert.notStrictEqual(code, 0) + assert.ok(/no wallet json file or hexseed specified/.test(out), out) + }) + + it('refuses an unreadable wallet file', async () => { + const {code, out} = await runLattice(['-w', '/tmp/glk-does-not-exist.json', '-i', '0']) + assert.notStrictEqual(code, 0) + assert.ok(/Unable to open wallet file: invalid wallet file/.test(out), out) + }) + + it('refuses an encrypted wallet opened with the wrong password', async () => { + const {code, out} = await runLattice(['-w', offlineEncWallet, '-p', 'not-the-password', '-i', '0']) + assert.notStrictEqual(code, 0) + assert.ok(/Unable to open wallet file: invalid/.test(out), out) + }) + + it('refuses to broadcast from a wallet file with no OTS index', async () => { + const {code, out} = await runLattice(['-w', offlineWallet, '-b', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/no OTS index given/.test(out), out) + }) + + it('generates keys without an OTS index when it is not broadcasting', async () => { + // The OTS index only matters for the transaction that registers the keys, so + // generating them locally must not demand one. + const {code, out} = await runLattice(['-w', offlineWallet]) + assert.strictEqual(code, 0, out) + assert.ok(/Kyber PK:/.test(out), out) + assert.ok(/Dilithium PK:/.test(out), out) + // kleur colours the labels, so match on the parts either side of the escapes + assert.ok(/tx_hash:/.test(out), out) + assert.ok(!/Transaction submitted to node/.test(out), 'nothing may be broadcast without -b') + }) + + it('refuses a fee that is not a number', async () => { + const {code, out} = await runLattice(['-s', wallet.hexseed, '-i', '0', '-f', 'free']) + assert.notStrictEqual(code, 0) + assert.ok(/Fee is invalid/.test(out), out) + }) + + // The mnemonic form of the seed reaches a different XMSS constructor than the + // hexseed form, and nothing offline had ever taken it. + it('accepts a 34 word mnemonic and an explicit fee, and writes the key file', async function fromMnemonic() { + this.timeout(120000) + const {code, out} = await runLattice([ + '-s', wallet.mnemonic, + '-i', '0', + '-f', '100', + '-c', offlineKeyFile, + ]) + assert.strictEqual(code, 0, out) + const keys = openFile(offlineKeyFile)[0] + assert.strictEqual(keys.encrypted, false) + assert.strictEqual(keys.tx_hash, 'false', 'nothing was broadcast') + assert.ok(keys.kyberPK && keys.dilithiumPK && keys.ecdsaPK, 'expected all three public keys') + }) + + // -b against a closed port: the command must give up at the connection and + // never reach the signing code, so no OTS key is spent. + it('gives up when the node cannot be reached, without signing anything', async function deadNode() { + this.timeout(120000) + const {code, out} = await runLattice(['-w', offlineWallet, '-i', '0', '-b', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), out) + assert.ok(!/Transaction signed/.test(out), `an OTS key was spent:\n${out}`) + assert.ok(!/Pushing transaction/.test(out), `a transaction was pushed:\n${out}`) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// Encrypted wallet handling +// +// A wrong password makes aes.decrypt throw, which lands in the catch that +// reports "invalid wallet file" -- so the branch that reports "invalid +// password" is only reachable with a file whose ciphertext decrypts cleanly to +// something that is not a QRL address. That is what is built here. +// +// The interactive branch (an encrypted wallet with no -p) cannot be driven +// through a pipe: cli-ux's hidden prompt shells out to `sh -c 'read -s'`, which +// spins forever when stdin is not a terminal. Running the command in-process +// with the prompt stubbed exercises the same branch without needing a pty, and +// the command exits on the bad address before any key generation starts. +// /////////////////////////////////////////////////////////////////////////// + +describe('generate-lattice-keys encrypted wallets', () => { + const junkWallet = '/tmp/glk-decrypts-to-junk.json' + const walletPassword = 'testpassword' + + before(() => { + fs.writeFileSync( + junkWallet, + JSON.stringify([ + { + encrypted: true, + address: aes.encrypt(walletPassword, 'not-a-qrl-address'), + hexseed: aes.encrypt(walletPassword, 'not-a-hexseed'), + }, + ]) + ) + }) + + after(() => { + try { + fs.unlinkSync(junkWallet) + } catch (error) { + // never created; nothing to clean up + } + }) + + it('blames the password when the wallet decrypts to something that is not an address', async () => { + const {code, out} = await runLattice(['-w', junkWallet, '-p', walletPassword, '-i', '0']) + assert.notStrictEqual(code, 0) + assert.ok(/Unable to open wallet file: invalid password/.test(out), out) + }) + + describe('with no -p on the command line', () => { + const {cli} = require('cli-ux') // eslint-disable-line global-require + const {Lattice} = require('../../src/commands/generate-lattice-keys') // eslint-disable-line global-require + + const root = path.join(__dirname, '..', '..') + let originalPrompt + let asked + + beforeEach(() => { + asked = [] + originalPrompt = Object.getOwnPropertyDescriptor(cli, 'prompt') + Object.defineProperty(cli, 'prompt', { + configurable: true, + value: async (message, options) => { + asked.push({message, options}) + return walletPassword + }, + }) + }) + + afterEach(() => { + Object.defineProperty(cli, 'prompt', originalPrompt) + }) + + it('asks for the wallet password, hidden', async () => { + const write = process.stdout.write.bind(process.stdout) + let out = '' + process.stdout.write = chunk => { + out += chunk.toString() + return true + } + let failed + try { + await Lattice.run(['-w', junkWallet, '-i', '0'], root) + } catch (error) { + failed = error + } finally { + process.stdout.write = write + } + assert.ok(failed, 'expected the command to exit non-zero') + assert.deepStrictEqual( + asked.map(a => a.message), + ['Enter password for wallet file'], + 'expected exactly one password prompt' + ) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + assert.ok(/Unable to open wallet file: invalid password/.test(out), out) + }) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// generate-lattice-keys: what happens once a node has answered +// +// The offline suites above never pass -b, so the lattice keys are generated and +// printed but nothing is ever broadcast. The broadcast half — the transaction +// the command asks the node to build, the binding check that decides whether to +// sign, the push, and the transaction-id check on the way back — only ran +// against a live node, so its failure branches were unreachable. +// +// These cases run the command in *this* process against a stub gRPC client. +// src/functions/grpc is swapped in the require cache for the moment it takes to +// require the command (it captures Qrlnode at require time), then the real +// module is put straight back. There is no server and no socket. +// +// The signing is real, against a throwaway height-4 wallet, and the stub +// recomputes the transaction hash the way a node does. Nothing reaches a +// network, so no on-chain OTS key is spent. +// /////////////////////////////////////////////////////////////////////////// + +const { + concatenateTypedArrays, + toBigendianUint64BytesUnsigned, + toUint8Vector, + binaryToBytes, +} = require('../../src/functions/tx-binding') + +// kleur colours by environment variable rather than by isTTY, so captured output +// still carries escape sequences. Strip them before matching. +const GLK_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +const GLK_NODE_WALLET = '/tmp/glk-node-wallet.json' + +// Behaviour the stub should show for the test currently running. Reset per test. +let glkNode = {} +let glkCalls = [] +let glkConnectAttempts = 0 + +// Rebuild the transaction hash from the signed transaction the command pushed, +// the way QRL core does for a LatticeTransaction: +// preimage = master_addr || fee || pk1 || pk2 || pk3 +// hash = sha256(sha256(preimage) || signature || public key) +function latticeTransactionHash(signedTx) { + const preimage = concatenateTypedArrays( + Uint8Array, + toBigendianUint64BytesUnsigned(parseInt(signedTx.fee, 10)), + Uint8Array.from(Buffer.from(signedTx.latticePK.pk1)), + Uint8Array.from(Buffer.from(signedTx.latticePK.pk2)), + Uint8Array.from(Buffer.from(signedTx.latticePK.pk3)) + ) + const digest = QRLLIB.sha2_256(toUint8Vector(preimage)) // eslint-disable-line no-undef + const whole = concatenateTypedArrays( + Uint8Array, + binaryToBytes(digest), + Uint8Array.from(signedTx.signature), + Uint8Array.from(signedTx.public_key) + ) + // eslint-disable-next-line no-undef + return Buffer.from(QRLLIB.bin2hstr(QRLLIB.sha2_256(toUint8Vector(whole))), 'hex') +} + +// What an honest node returns for GetLatticeTxn: the request echoed back. +const buildLatticeResponse = request => ({ + extended_transaction_unsigned: { + tx: { + master_addr: Buffer.from(request.master_addr), + fee: String(request.fee), + latticePK: { + pk1: Buffer.from(request.pk1), + pk2: Buffer.from(request.pk2), + pk3: Buffer.from(request.pk3), + }, + }, + }, +}) + +class LatticeFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + glkConnectAttempts += 1 + if (glkNode.connectThrows) { + throw new Error(glkNode.connectThrows) + } + const connectsOn = glkNode.connectsOnAttempt === undefined ? 1 : glkNode.connectsOnAttempt + if (connectsOn !== 0 && glkConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + glkCalls.push({name, request}) + if (name === 'GetLatticeTxn') { + const built = buildLatticeResponse(request) + return glkNode.tamper ? glkNode.tamper(built) : built + } + if (glkNode.pushResponse) { + return glkNode.pushResponse + } + const hash = latticeTransactionHash(request.transaction_signed) + return {tx_hash: glkNode.wrongHash ? Buffer.alloc(32, 0x11) : hash} + } +} + +const glkGrpcPath = require.resolve('../../src/functions/grpc') +const glkCommandPath = require.resolve('../../src/commands/generate-lattice-keys') + +// This file already required the command higher up (the password-prompt suite), +// so the cached copy holds the real gRPC client. Drop it, require it again with +// the stub in place, and put the original back when this suite is done. +const glkRealCommandEntry = require.cache[glkCommandPath] +delete require.cache[glkCommandPath] + +const glkRealGrpcEntry = require.cache[glkGrpcPath] +require.cache[glkGrpcPath] = { + id: glkGrpcPath, + filename: glkGrpcPath, + path: path.dirname(glkGrpcPath), + loaded: true, + children: [], + paths: [], + exports: LatticeFakeQrlNode, +} +const {Lattice} = require('../../src/commands/generate-lattice-keys') + +if (glkRealGrpcEntry) { + require.cache[glkGrpcPath] = glkRealGrpcEntry +} else { + delete require.cache[glkGrpcPath] +} + +// Run generate-lattice-keys here, against the stub, capturing everything it prints (this.log +// and console.log go to stdout, the ora spinners go to stderr). run() awaits the whole +// signing and pushing sequence, so an exit inside it arrives as a thrown ExitError. +async function runLatticeInProcess(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = chunk => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await Lattice.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + // kleur colours by environment variable rather than by isTTY, so the captured output + // still carries escape sequences here. Strip them so the assertions read as the text a + // person would see. + return {code, out: chunks.join('').replace(GLK_ANSI, '')} +} + +describe('generate-lattice-keys: broadcasting to a node', () => { + before(async function makeNodeWallet() { + this.timeout(120000) + await makeWallet(['-f', GLK_NODE_WALLET]) + }) + + after(() => { + // The cached command module holds the stubbed Qrlnode; put the real one back + // so anything requiring it later in the same process is unaffected. + if (glkRealCommandEntry === undefined) { + delete require.cache[glkCommandPath] + } else { + require.cache[glkCommandPath] = glkRealCommandEntry + } + try { + fs.unlinkSync(GLK_NODE_WALLET) + } catch (error) { + // never created; nothing to clean up + } + }) + + beforeEach(() => { + glkNode = {} + glkCalls = [] + glkConnectAttempts = 0 + }) + + const latticeArgs = (extra = []) => ['-w', GLK_NODE_WALLET, '-i', '0', '-b', '-t', ...extra] + + it('asks the node to register the three public keys it just generated', async function builds() { + this.timeout(180000) + const {code, out} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 0, out) + const build = glkCalls.find(c => c.name === 'GetLatticeTxn') + assert.ok(build, `no GetLatticeTxn call was made\n--- output ---\n${out}`) + // kyber, dilithium and ecdsa public keys, each non-empty and distinct + assert.ok(build.request.pk1.length > 0 && build.request.pk2.length > 0 && build.request.pk3.length > 0) + assert.notStrictEqual(Buffer.from(build.request.pk1).toString('hex'), Buffer.from(build.request.pk2).toString('hex')) + assert.strictEqual(build.request.fee, 0) + }) + + it('accepts an explicit fee of 0, the same value it uses when -f is omitted', async function zeroFee() { + this.timeout(180000) + const {code, out} = await runLatticeInProcess(latticeArgs(['-f', '0'])) + assert.strictEqual(code, 0, out) + assert.strictEqual(glkCalls.find(c => c.name === 'GetLatticeTxn').request.fee, 0) + }) + + it('signs, pushes, and reports the id the node gave back', async function signs() { + this.timeout(180000) + const {code, out} = await runLatticeInProcess(latticeArgs(['-f', '100'])) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + const push = glkCalls.find(c => c.name === 'PushTransaction') + assert.ok(push.request.transaction_signed.signature.length > 0, 'pushed without a signature') + assert.strictEqual(push.request.transaction_signed.fee, '100') + const hash = latticeTransactionHash(push.request.transaction_signed).toString('hex') + assert.ok(out.includes(`transaction ID: ${hash}`), out) + assert.ok(out.includes(`https://testnet-explorer.theqrl.org/tx/${hash}`), out) + }) + + it('links to the mainnet explorer when broadcasting to mainnet', async function mainnet() { + this.timeout(180000) + const {code, out} = await runLatticeInProcess( + ['-w', GLK_NODE_WALLET, '-i', '0', '-b', '-m'] + ) + assert.strictEqual(code, 0, out) + assert.ok(/https:\/\/explorer\.theqrl\.org\/tx\/[0-9a-f]{64}/.test(out), out) + }) + + it('reports the transaction id on a custom endpoint too', async function customEndpoint() { + this.timeout(180000) + // No explorer to link to for a custom node, but the transaction still has to + // be named, or a successful broadcast leaves nothing to look it up with. + const {code, out} = await runLatticeInProcess(['-w', GLK_NODE_WALLET, '-i', '0', '-b', '-g', DEAD_NODE]) + assert.strictEqual(code, 0, out) + const push = glkCalls.find(c => c.name === 'PushTransaction') + const hash = latticeTransactionHash(push.request.transaction_signed).toString('hex') + assert.ok(out.includes(`transaction ID: ${hash}`), out) + assert.ok(!/explorer\.theqrl\.org/.test(out), 'no explorer link for a network with no explorer') + }) + + it('retries the connection until the node answers', async function retries() { + this.timeout(180000) + glkNode = {connectsOnAttempt: 3} + const {code} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 0) + assert.strictEqual(glkConnectAttempts, 3) + }) + + it('refuses to sign a response that swapped a public key', async function tamperedKey() { + this.timeout(180000) + // Registering a lattice key the user does not hold the secret half of would + // let a node substitute its own key for every later encrypted exchange. + glkNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.latticePK.pk2 = Buffer.alloc(tx.latticePK.pk2.length, 0xAB) + return response + }, + } + const {code, out} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different dilithium public key/.test(out), out) + assert.ok(/Nothing was signed and no OTS key was used/.test(out), out) + assert.strictEqual(glkCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('refuses to sign a response that inflated the fee', async function tamperedFee() { + this.timeout(180000) + glkNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.fee = '100000000' + return response + }, + } + const {code, out} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different fee/.test(out), out) + }) + + it('reports a node that rejects the push', async function pushRejected() { + this.timeout(180000) + glkNode = {pushResponse: {error_code: 'INVALID', error_description: 'OTS key reused'}} + const {code, out} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/OTS key reused/.test(out), out) + }) + + it('refuses a transaction id that is not the one it signed', async function hashMismatch() { + this.timeout(180000) + glkNode = {wrongHash: true} + const {code, out} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/Node transaction hash 1111/.test(out), out) + }) + + it('reports a connection that fails outright', async function connectFailed() { + this.timeout(180000) + glkNode = {connectThrows: 'no route to host'} + const {code, out} = await runLatticeInProcess(latticeArgs()) + assert.strictEqual(code, 1, out) + assert.strictEqual(glkCalls.length, 0, 'nothing may be asked of a node that never connected') + }) + it('reports a signing failure that is not a binding failure', async function badOts() { + this.timeout(180000) + // OTS index 999 does not exist in a height-4 tree, so the response binds + // cleanly and it is xmss.sign() that fails. + const {code, out} = await runLatticeInProcess( + ['-w', GLK_NODE_WALLET, '-i', '999', '-b', '-t'] + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(glkCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) +}) diff --git a/test/commands/generate-shared-keys.offline-runner.js b/test/commands/generate-shared-keys.offline-runner.js new file mode 100644 index 0000000..6a57112 --- /dev/null +++ b/test/commands/generate-shared-keys.offline-runner.js @@ -0,0 +1,114 @@ +// ////////////////////////////////////////////////////////////////////////// +// Offline driver for `generate-shared-keys` +// +// `generate-shared-keys` unconditionally opens a gRPC connection to a QRL +// node (src/commands/generate-shared-keys.js, section "0.b") *before* it runs +// any of the Kyber/Dilithium maths, so with no node reachable the command +// exits at that point and none of the lattice crypto is ever executed. +// +// This runner executes the command in-process with the two network methods of +// the Qrlnode class replaced by local stubs, so the offline crypto can be +// driven without a node and without a single byte crossing the network. It is +// *not* a mock node: no socket is opened, no server is started, and only the +// two methods below are replaced. +// +// Behaviour is controlled with environment variables: +// GSK_API 'notfound' | 'nolattice' | 'lattice' canned GetObject reply +// GSK_PUBFILE path of a public lattice key JSON file used to build the +// 'lattice' reply +// GSK_PROMPT_PASSWORD answer to give at the hidden lattice-password prompt, +// standing in for what a person would type at a terminal +// GSK_CONNECT_ON attempt number on which connect() first succeeds, so the +// command's retry loop can be driven (default 1: straight away) +// +// Everything after the flags is passed through to the command as argv. +// ////////////////////////////////////////////////////////////////////////// + +const fs = require('fs') +const path = require('path') + +const projectRoot = path.join(__dirname, '..', '..') +const log = (msg) => process.stderr.write(`${msg}\n`) + +// qrllib installs its own `unhandledRejection` handler that silently calls +// process.exit(1); ours is registered first so the reason is at least visible. +process.on('unhandledRejection', (err) => { + log(`runner unhandledRejection: ${(err && (err.stack || err.message)) || err}`) +}) + +const Qrlnode = require('../../src/functions/grpc') + +const hexBuf = (hex) => Buffer.from(hex, 'hex') + +const apiResponse = () => { + const mode = process.env.GSK_API || 'notfound' + if (mode === 'notfound') { + return {found: false} + } + if (mode === 'nolattice') { + return { + found: true, + transaction: {addr_from: hexBuf('0102030405'), tx: {}}, + } + } + // 'lattice': a lattice transaction carrying the public keys of GSK_PUBFILE + const pub = JSON.parse(fs.readFileSync(process.env.GSK_PUBFILE))[1] + return { + found: true, + transaction: { + addr_from: hexBuf('0102030405'), + tx: { + latticePK: { + pk1: hexBuf(pub.pk1), + pk2: hexBuf(pub.pk2), + pk3: hexBuf(pub.pk3), + }, + transaction_hash: hexBuf(pub.tx_hash), + }, + }, + } +} + +let connectAttempts = 0 +Qrlnode.prototype.connect = async function stubConnect() { + connectAttempts += 1 + const connectsOn = parseInt(process.env.GSK_CONNECT_ON || '1', 10) + if (connectAttempts >= connectsOn) { + this.connection = true + this.client = {} + } + return this.client +} + +Qrlnode.prototype.api = async function stubApi() { + return apiResponse() +} + +// cli-ux exposes `prompt` as a getter, so it has to be redefined rather than assigned. +// The command asks for the lattice password whenever -d is absent, which needs a terminal. +if (process.env.GSK_PROMPT_PASSWORD !== undefined) { + const {cli} = require('cli-ux') // eslint-disable-line global-require + Object.defineProperty(cli, 'prompt', { + configurable: true, + get: () => async () => process.env.GSK_PROMPT_PASSWORD, + }) +} + +// required *after* the stubs are installed so the command picks them up +const {LatticeShared} = require('../../src/commands/generate-shared-keys') + +// run() awaits every one of the command's key-derivation callbacks, so by the time it +// resolves the key list and cyphertext files are on disk. This used not to be true, and +// the runner had to poll for them before it could exit. +const main = async () => { + await LatticeShared.run(process.argv.slice(2), projectRoot) + return 0 +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + log(`runner error: ${(err && err.message) || err}`) + const exitCode = (err && err.oclif && err.oclif.exit) || 1 + process.exit(exitCode) + }) diff --git a/test/commands/generate-shared-keys.test.js b/test/commands/generate-shared-keys.test.js index 9d370dd..d20857e 100644 --- a/test/commands/generate-shared-keys.test.js +++ b/test/commands/generate-shared-keys.test.js @@ -6,7 +6,10 @@ const assert = require('assert') const {spawn} = require('child_process') const fs = require('fs') +const os = require('os') +const path = require('path') const setup = require('../test_setup') +const aesUtil = require('../../src/utils/aes') // Suites needing on-chain state (skipped in offline mock mode: hooks create no broadcast txs) const describeOnline = process.env.QRL_TEST_OFFLINE === 'true' ? describe.skip : describe @@ -1263,3 +1266,707 @@ describeOnline('generate-shared-keys #2d', () => { assert.strictEqual(exitCode, 0) }) }) + +// /////////////////////////////////////////////////////////////////////////// +// Offline lattice crypto coverage +// +// Everything above this line stops before the Kyber/Dilithium maths runs: +// generate-shared-keys opens a gRPC connection to a node (section "0.b" of the +// command) *before* it touches any key material, so with no node reachable the +// command exits there and none of the crypto is ever executed. That is why the +// end-to-end cases above are all gated behind describeOnline, and why they +// contribute nothing in CI, which runs with QRL_TEST_OFFLINE=true. +// +// The crypto itself needs no node at all. The cases below drive it through +// test/commands/generate-shared-keys.offline-runner.js, which runs the command +// in-process with Qrlnode.prototype.connect/api replaced by local stubs. No +// socket is opened and no server is started; only those two methods are +// replaced, and every case additionally passes -g 127.0.0.1:1 so that even a +// stub failure could only ever reach a closed loopback port. +// +// The fixtures are generated locally in the before() hook below (a wallet and +// two lattice key pairs, neither broadcast, so no OTS key is consumed). +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback. Nothing leaves the machine. +const DEAD_NODE = '127.0.0.1:1' +const RUNNER = path.join(__dirname, 'generate-shared-keys.offline-runner.js') + +// Own directory: the shared hooks empty test/lattice, and other suites run in parallel. +const OFFLINE = path.join(os.tmpdir(), 'qrl-cli-shared-keys-offline') +const off = (name) => path.join(OFFLINE, name) + +const WALLET = off('wallet.json') +const ALICE_SK = off('alice-lattice.json') +const BOB_SK = off('bob-lattice.json') +const ALICE_PUB = off('alice-pub.json') +const BOB_PUB = off('bob-pub.json') +const ALICE_SK_ENC = off('alice-lattice-enc.json') // decrypts to network "Testnet" +const ALICE_SK_ENC_BAD_NETWORK = off('alice-lattice-enc-bad-network.json') +const BASE_CIPHERTEXT = off('base-cyphertext.txt') +const BASE_SIGNED = off('base-signed-message.txt') +const BASE_KEYLIST = off('base-keylist.txt') +const WHITESPACE = off('whitespace.txt') +const NO_ENTRIES = off('no-entries.txt') // [] +const NO_KEYS = off('no-keys.txt') // [{}] +const NOT_JSON = off('not-json.txt') +const LATTICE_PASSWORD = 'password123' +const TX_HASH = 'ab'.repeat(32) + +// A shared key list is shake128(64000) rendered as hex. +const KEYLIST_LENGTH = 128000 + +// Run the offline runner and capture what the command said. +function runOffline(args, env) { + return new Promise((resolve) => { + const child = spawn('node', [RUNNER].concat(args, ['-g', DEAD_NODE]), { + stdio: ['ignore', 'pipe', 'pipe'], + env: {...process.env, ...(env || {})}, + }) + let out = '' + child.stdout.on('data', (d) => { + out += d.toString() + }) + child.stderr.on('data', (d) => { + out += d.toString() + }) + child.on('close', (code) => resolve({code, out})) + }) +} + +// Run the real CLI, for the paths that do not need a node at all. +function runCli(args) { + return new Promise((resolve) => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', (d) => { + out += d.toString() + }) + child.stderr.on('data', (d) => { + out += d.toString() + }) + child.on('close', (code) => resolve({code, out})) + }) +} + +const readJson = (file) => JSON.parse(fs.readFileSync(file)) + +// The public key file layout `get-keys` writes: [{address, network}, {pk1, pk2, pk3, tx_hash}] +function publicKeyFile(secretKeyFile, address) { + const keys = readJson(secretKeyFile)[0] + return [ + {address, network: 'Testnet'}, + {pk1: keys.kyberPK, pk2: keys.dilithiumPK, pk3: keys.ecdsaPK, tx_hash: TX_HASH}, + ] +} + +// An encrypted lattice key file, as `generate-lattice-keys -e` writes them. +function encryptedKeyFile(secretKeyFile, password, network) { + const keys = readJson(secretKeyFile)[0] + const enc = (value) => aesUtil.encrypt(password, value) + return [ + { + encrypted: true, + tx_hash: enc(TX_HASH), + network: enc(network), + kyberPK: enc(keys.kyberPK), + kyberSK: enc(keys.kyberSK), + dilithiumPK: enc(keys.dilithiumPK), + dilithiumSK: enc(keys.dilithiumSK), + ecdsaPK: enc(keys.ecdsaPK), + ecdsaSK: enc(keys.ecdsaSK), + }, + ] +} + +describe('generate-shared-keys offline crypto', () => { + let aliceSecretJson + let bobPublicJson + let encryptedSecretJson + let baseCipherText + let baseSignedMessage + let baseKeyList + + // Fixture generation is the slow part (two lattice key pairs and one full + // shared key exchange); it happens once and every case below reuses it. + before(async function generateFixtures() { + this.timeout(300000) + // Start from nothing: the CLI will not overwrite a wallet that is already + // there, and this directory survives between runs on a persistent machine. + fs.rmSync(OFFLINE, {recursive: true, force: true}) + fs.mkdirSync(OFFLINE, {recursive: true}) + // No -b anywhere: nothing is broadcast, so no OTS key is used. + await runCli(['create-wallet', '-3', '-h', '6', '-f', WALLET]) + await runCli(['generate-lattice-keys', '-w', WALLET, '-i', '1', '-c', ALICE_SK]) + await runCli(['generate-lattice-keys', '-w', WALLET, '-i', '2', '-c', BOB_SK]) + + fs.writeFileSync(ALICE_PUB, JSON.stringify(publicKeyFile(ALICE_SK, 'Q010203'))) + fs.writeFileSync(BOB_PUB, JSON.stringify(publicKeyFile(BOB_SK, 'Q040506'))) + fs.writeFileSync(ALICE_SK_ENC, JSON.stringify(encryptedKeyFile(ALICE_SK, LATTICE_PASSWORD, 'Testnet'))) + // "false" is what an un-broadcast key file carries, and it is not one of the + // three network names the command accepts after decryption. + fs.writeFileSync( + ALICE_SK_ENC_BAD_NETWORK, + JSON.stringify(encryptedKeyFile(ALICE_SK, LATTICE_PASSWORD, 'false')) + ) + fs.writeFileSync(WHITESPACE, ' \n ') + fs.writeFileSync(NO_ENTRIES, '[]') + fs.writeFileSync(NO_KEYS, '[{}]') + fs.writeFileSync(NOT_JSON, 'this is not json') + + aliceSecretJson = fs.readFileSync(ALICE_SK, 'utf8') + bobPublicJson = fs.readFileSync(BOB_PUB, 'utf8') + encryptedSecretJson = fs.readFileSync(ALICE_SK_ENC, 'utf8') + + // One canonical exchange, used as the input of every "Bob" case below. + await runOffline([BOB_PUB, ALICE_SK, '-c', BASE_CIPHERTEXT, '-s', BASE_SIGNED, '-k', BASE_KEYLIST]) + baseCipherText = JSON.stringify(readJson(BASE_CIPHERTEXT)[0]) + baseSignedMessage = fs.readFileSync(BASE_SIGNED, 'utf8') + baseKeyList = fs.readFileSync(BASE_KEYLIST, 'utf8') + }) + + // ------------------------------------------------------------------------- + // The connection gate itself + // ------------------------------------------------------------------------- + describe('generate-shared-keys #36 - node unreachable', () => { + let result + before(async function connectFails() { + this.timeout(120000) + result = await runCli([ + 'generate-shared-keys', + BOB_PUB, + ALICE_SK, + '-c', off('unused-cyphertext.txt'), + '-s', off('unused-signed.txt'), + '-k', off('unused-keylist.txt'), + '-g', DEAD_NODE, + ]) + }) + it('says it could not reach the node, with valid key material on both sides', () => { + assert.ok(result.out.includes('Failed to connect to node'), result.out) + assert.notStrictEqual(result.code, 0) + }) + it('writes no output files when it cannot reach a node', () => { + assert.strictEqual(fs.existsSync(off('unused-keylist.txt')), false) + }) + }) + + // ------------------------------------------------------------------------- + // Case #1: Alice generates + // ------------------------------------------------------------------------- + describe('generate-shared-keys #37 - alice generates a shared key list', () => { + let result + before(async function aliceGenerates() { + this.timeout(200000) + result = await runOffline( + [BOB_PUB, ALICE_SK, '-c', off('a37-ct.txt'), '-s', off('a37-sm.txt'), '-k', off('a37-kl.txt')] + ) + }) + it('reports the recipient address it generated secrets for', () => { + assert.ok(result.out.includes('Generating new shared secrets for'), result.out) + assert.ok(result.out.includes('Address: Q040506'), result.out) + assert.strictEqual(result.code, 0) + }) + it('writes the cyphertext as an eccrypto payload', () => { + const cipher = readJson(off('a37-ct.txt'))[0] + assert.deepStrictEqual(Object.keys(cipher).sort(), ['ciphertext', 'ephemPublicKey', 'iv', 'mac']) + }) + it('writes a dilithium signed message of the expected length', () => { + assert.strictEqual(readJson(off('a37-sm.txt'))[0].length, 5466) + }) + it('writes a shake128 key list of 64000 bytes', () => { + const keylist = fs.readFileSync(off('a37-kl.txt'), 'utf8') + assert.strictEqual(keylist.length, KEYLIST_LENGTH) + assert.ok(/^[0-9a-f]+$/.test(keylist)) + }) + it('writes the key list with 0600 permissions', () => { + // eslint-disable-next-line no-bitwise + assert.strictEqual(fs.statSync(off('a37-kl.txt')).mode & 0o777, 0o600) + }) + }) + + // ------------------------------------------------------------------------- + // Case #2: Bob regenerates. This is the property that matters: both sides + // must derive the same key list from the same exchange. + // ------------------------------------------------------------------------- + describe('generate-shared-keys #38 - bob regenerates the same key list', () => { + let result + before(async function bobRegenerates() { + this.timeout(200000) + result = await runOffline( + [ALICE_PUB, BOB_SK, BASE_CIPHERTEXT, BASE_SIGNED, '-k', off('b38-kl.txt')] + ) + }) + it('reports that it found the shared secrets', () => { + assert.ok(result.out.includes('Shared secrets found, decrypting and generating shared keylist'), result.out) + assert.ok(result.out.includes('Keylist generated and written to'), result.out) + assert.strictEqual(result.code, 0) + }) + it('derives byte for byte the key list alice generated', () => { + assert.strictEqual(fs.readFileSync(off('b38-kl.txt'), 'utf8'), baseKeyList) + }) + }) + + // ------------------------------------------------------------------------- + // Output and input format variations + // ------------------------------------------------------------------------- + describe('generate-shared-keys #39 - key list encrypted with -e', () => { + let result + before(async function encryptedKeylist() { + this.timeout(200000) + result = await runOffline( + [ + BOB_PUB, ALICE_SK, + '-c', off('a39-ct.txt'), '-s', off('a39-sm.txt'), '-k', off('a39-kl.txt'), + '-e', 'keylistpassword', + ] + ) + }) + it('writes an encrypted key list rather than the raw hex', () => { + assert.ok(result.out.includes('Shared Key List file written to'), result.out) + const keylist = fs.readFileSync(off('a39-kl.txt'), 'utf8') + assert.ok(keylist.startsWith('v2:'), keylist.slice(0, 40)) + assert.strictEqual(aesUtil.decrypt('keylistpassword', keylist).length, KEYLIST_LENGTH) + }) + }) + + describe('generate-shared-keys #40 - keys given as JSON rather than files', () => { + let result + before(async function jsonKeys() { + this.timeout(200000) + result = await runOffline( + [ + bobPublicJson, aliceSecretJson, + '-c', off('a40-ct.txt'), '-s', off('a40-sm.txt'), '-k', off('a40-kl.txt'), + ] + ) + }) + it('accepts both sides as JSON strings', () => { + assert.ok(result.out.includes('Address: Q040506'), result.out) + assert.strictEqual(fs.readFileSync(off('a40-kl.txt'), 'utf8').length, KEYLIST_LENGTH) + assert.strictEqual(result.code, 0) + }) + }) + + describe('generate-shared-keys #41 - encrypted lattice key file', () => { + let result + before(async function encryptedFile() { + this.timeout(200000) + result = await runOffline( + [ + BOB_PUB, ALICE_SK_ENC, + '-c', off('a41-ct.txt'), '-s', off('a41-sm.txt'), '-k', off('a41-kl.txt'), + '-d', LATTICE_PASSWORD, + ] + ) + }) + it('decrypts the key file and generates the same key list as the plaintext keys', () => { + assert.ok(result.out.includes('Shared Key List file written to'), result.out) + assert.strictEqual(fs.readFileSync(off('a41-kl.txt'), 'utf8').length, KEYLIST_LENGTH) + assert.strictEqual(result.code, 0) + }) + }) + + describe('generate-shared-keys #42 - encrypted lattice keys as JSON', () => { + let result + before(async function encryptedJson() { + this.timeout(200000) + result = await runOffline( + [ + BOB_PUB, encryptedSecretJson, + '-c', off('a42-ct.txt'), '-s', off('a42-sm.txt'), '-k', off('a42-kl.txt'), + '-d', LATTICE_PASSWORD, + ] + ) + }) + it('decrypts JSON passed on the command line', () => { + assert.strictEqual(fs.readFileSync(off('a42-kl.txt'), 'utf8').length, KEYLIST_LENGTH) + assert.strictEqual(result.code, 0) + }) + }) + + describe('generate-shared-keys #41a - a node that answers on a later attempt', () => { + let result + before(async function retriedConnection() { + this.timeout(200000) + // The command retries the connection up to five times before giving up. The + // public-key lookup below is the only thing that needs the node at all. + result = await runOffline( + [ + BOB_PUB, ALICE_SK, + '-c', off('a41a-ct.txt'), '-s', off('a41a-sm.txt'), '-k', off('a41a-kl.txt'), + ], + {GSK_CONNECT_ON: '3'} + ) + }) + + it('reports each retry', () => { + assert.ok(/retry connection attempt: 0/.test(result.out), result.out) + assert.ok(/retry connection attempt: 1/.test(result.out), result.out) + }) + + it('carries on and generates the key list once it is through', () => { + assert.strictEqual(result.code, 0, result.out) + assert.strictEqual(fs.readFileSync(off('a41a-kl.txt'), 'utf8').length, KEYLIST_LENGTH) + }) + }) + + describe('generate-shared-keys #42a - encrypted lattice keys with no -d flag', () => { + let fileResult + let jsonResult + before(async function promptedPassword() { + this.timeout(200000) + // Without -d the password is asked for at the terminal, which is the only + // way to use an encrypted key file without putting its password in shell + // history. Both the file and the JSON form ask separately. + fileResult = await runOffline( + [ + BOB_PUB, ALICE_SK_ENC, + '-c', off('a42a-ct.txt'), '-s', off('a42a-sm.txt'), '-k', off('a42a-kl.txt'), + ], + {GSK_PROMPT_PASSWORD: LATTICE_PASSWORD} + ) + jsonResult = await runOffline( + [ + BOB_PUB, encryptedSecretJson, + '-c', off('a42b-ct.txt'), '-s', off('a42b-sm.txt'), '-k', off('a42b-kl.txt'), + ], + {GSK_PROMPT_PASSWORD: LATTICE_PASSWORD} + ) + }) + + it('takes the password typed at the prompt for a key file', () => { + assert.strictEqual(fileResult.code, 0, fileResult.out) + assert.strictEqual(fs.readFileSync(off('a42a-kl.txt'), 'utf8').length, KEYLIST_LENGTH) + }) + + it('takes the password typed at the prompt for keys given as JSON', () => { + assert.strictEqual(jsonResult.code, 0, jsonResult.out) + assert.strictEqual(fs.readFileSync(off('a42b-kl.txt'), 'utf8').length, KEYLIST_LENGTH) + }) + }) + + describe('generate-shared-keys #43 - decrypted keys carry no usable network', () => { + let fileResult + let jsonResult + before(async function badNetwork() { + this.timeout(200000) + const args = (secret) => [ + BOB_PUB, secret, + '-c', off('a43-ct.txt'), '-s', off('a43-sm.txt'), '-k', off('a43-kl.txt'), + '-d', LATTICE_PASSWORD, + ] + fileResult = await runOffline(args(ALICE_SK_ENC_BAD_NETWORK)) + jsonResult = await runOffline(args(fs.readFileSync(ALICE_SK_ENC_BAD_NETWORK, 'utf8'))) + }) + it('rejects a key file whose network is not Testnet/Mainnet/GRPC', () => { + // The file branch wraps this check in a try/catch, so the exit surfaces + // as a decryption failure rather than as the "Bad passphrase" message. + assert.ok(fileResult.out.includes('Failed to decrypt'), fileResult.out) + assert.notStrictEqual(fileResult.code, 0) + }) + it('rejects the same keys passed as JSON', () => { + assert.ok(jsonResult.out.includes('Data still encrypted... Bad passphrase?'), jsonResult.out) + assert.notStrictEqual(jsonResult.code, 0) + }) + }) + + // ------------------------------------------------------------------------- + // Public keys fetched from a transaction hash + // ------------------------------------------------------------------------- + describe('generate-shared-keys #44 - transaction hash lookups', () => { + let notFound + let notLattice + let latticeFound + before(async function txLookups() { + this.timeout(200000) + const args = [TX_HASH, ALICE_SK, '-c', off('a44-ct.txt'), '-s', off('a44-sm.txt'), '-k', off('a44-kl.txt')] + notFound = await runOffline(args, {GSK_API: 'notfound'}) + notLattice = await runOffline(args, {GSK_API: 'nolattice'}) + latticeFound = await runOffline(args, {GSK_API: 'lattice', GSK_PUBFILE: BOB_PUB}) + }) + it('fails when the node has no such transaction', () => { + assert.ok(notFound.out.includes('Unable to find transaction'), notFound.out) + assert.notStrictEqual(notFound.code, 0) + }) + it('fails when the transaction is not a lattice transaction', () => { + assert.ok(notLattice.out.includes('No lattice transaction found'), notLattice.out) + assert.notStrictEqual(notLattice.code, 0) + }) + it('rejects the keys it built from a lattice transaction', () => { + // Bug: the on-chain branch builds the entry with a `txHash` key, while the + // validator (and `get-keys`) require `tx_hash`, so a lookup by transaction + // hash can never succeed however good the transaction is. + assert.ok(latticeFound.out.includes('Grabbing public keys from'), latticeFound.out) + assert.ok(latticeFound.out.includes('Output #1 does not have a tx_hash'), latticeFound.out) + assert.notStrictEqual(latticeFound.code, 0) + }) + }) + + describe('generate-shared-keys #45 - pubKeyIndex flag', () => { + let result + before(async function pubKeyIndex() { + this.timeout(120000) + result = await runOffline([ + BOB_PUB, ALICE_SK, + '-c', off('a45-ct.txt'), '-s', off('a45-sm.txt'), '-k', off('a45-kl.txt'), + '-i', '1', + ]) + }) + it('cannot use -i at all', () => { + // Bug: pubKeyIndex is a string flag, and the command calls .toNumber() on it. + assert.ok(result.out.includes('flags.pubKeyIndex.toNumber is not a function'), result.out) + }) + }) + + describe('generate-shared-keys #46 - public keys with no entries', () => { + let result + before(async function emptyPublicArray() { + this.timeout(120000) + result = await runOffline([ + '[]', ALICE_SK, + '-c', off('a46-ct.txt'), '-s', off('a46-sm.txt'), '-k', off('a46-kl.txt'), + ]) + }) + it('passes validation and then falls over reading the keys', () => { + // An empty array satisfies checkLatticeJSON (it checks a one entry secret + // file and a two entry public file, and says nothing about zero entries). + assert.ok(result.out.includes("Cannot read properties of undefined (reading 'pk1')"), result.out) + }) + }) + + // ------------------------------------------------------------------------- + // Case #2 input validation, none of which is reachable without a node + // ------------------------------------------------------------------------- + describe('generate-shared-keys #47 - only one half of the exchange', () => { + let result + before(async function halfExchange() { + this.timeout(120000) + result = await runOffline([ALICE_PUB, BOB_SK, BASE_CIPHERTEXT, '-k', off('a47-kl.txt')]) + }) + it('requires both the cyphertext and the signed message', () => { + assert.ok(result.out.includes('Both Shared Secret and Shared Keys are required'), result.out) + assert.notStrictEqual(result.code, 0) + }) + }) + + describe('generate-shared-keys #48 - unusable cyphertext', () => { + const results = {} + before(async function badCipherText() { + this.timeout(200000) + const run = async (key, cypher) => { + results[key] = await runOffline([ALICE_PUB, BOB_SK, cypher, BASE_SIGNED, '-k', off('a48-kl.txt')]) + } + const without = (field) => { + const cipher = JSON.parse(baseCipherText) + delete cipher[field] + return JSON.stringify(cipher) + } + await run('noEntries', NO_ENTRIES) + await run('noKeys', NO_KEYS) + await run('notJson', 'definitely not json') + await run('iv', without('iv')) + await run('ephemPublicKey', without('ephemPublicKey')) + await run('ciphertext', without('ciphertext')) + await run('mac', without('mac')) + }) + it('rejects a cyphertext file with no entries', () => { + assert.ok(results.noEntries.out.includes('encCipherTextJson... array is undefined'), results.noEntries.out) + }) + it('rejects a cyphertext entry with no keys', () => { + assert.ok(results.noKeys.out.includes('encCipherTextJson... length of array is 0'), results.noKeys.out) + }) + it('rejects a cyphertext that is neither a file nor JSON', () => { + assert.ok(results.notJson.out.includes('No valid cyphertext JSON data passed'), results.notJson.out) + }) + it('names the eccrypto field that is missing', () => { + assert.ok(results.iv.out.includes('does not have a iv key buffer'), results.iv.out) + assert.ok(results.ephemPublicKey.out.includes('does not have a ephemPublicKey key buffer'), results.ephemPublicKey.out) + assert.ok(results.ciphertext.out.includes('does not have a ciphertext key buffer'), results.ciphertext.out) + assert.ok(results.mac.out.includes('does not have a mac key buffer'), results.mac.out) + }) + it('exits non-zero for every one of them', () => { + Object.keys(results).forEach((key) => { + assert.notStrictEqual(results[key].code, 0, key) + }) + }) + }) + + describe('generate-shared-keys #49 - cyphertext passed as JSON', () => { + let result + before(async function cipherTextJson() { + this.timeout(200000) + result = await runOffline([ALICE_PUB, BOB_SK, baseCipherText, BASE_SIGNED, '-k', off('a49-kl.txt')]) + }) + it('derives the same key list from a cyphertext given on the command line', () => { + assert.strictEqual(fs.readFileSync(off('a49-kl.txt'), 'utf8'), baseKeyList) + assert.strictEqual(result.code, 0) + }) + }) + + describe('generate-shared-keys #50 - unusable signed message', () => { + const results = {} + before(async function badSignedMessage() { + this.timeout(200000) + const run = async (key, signed) => { + results[key] = await runOffline([ALICE_PUB, BOB_SK, BASE_CIPHERTEXT, signed, '-k', off('a50-kl.txt')]) + } + await run('noEntries', '[]') + await run('notJson', 'definitely not json') + await run('tooShort', '["abc"]') + await run('shortFile', NO_KEYS) + }) + it('rejects a signed message array with no entries', () => { + assert.ok(results.noEntries.out.includes('Signed Message JSON... array is undefined'), results.noEntries.out) + }) + it('reports invalid JSON and then the empty message it was left with', () => { + assert.ok(results.notJson.out.includes('invalid signed message json'), results.notJson.out) + assert.ok(results.notJson.out.includes('Signed Message JSON... array is undefined'), results.notJson.out) + }) + it('rejects a message that is not 5466 characters', () => { + assert.ok(results.tooShort.out.includes('Invalid output length 3, expected message length is 5466'), results.tooShort.out) + }) + it('rejects a signed message file whose entry is the wrong shape', () => { + assert.ok(results.shortFile.out.includes('Invalid JSON found in Signed Message JSON'), results.shortFile.out) + }) + it('exits non-zero for every one of them', () => { + Object.keys(results).forEach((key) => { + assert.notStrictEqual(results[key].code, 0, key) + }) + }) + }) + + describe('generate-shared-keys #51 - signed message passed as JSON', () => { + let result + before(async function signedMessageJson() { + this.timeout(120000) + result = await runOffline([ALICE_PUB, BOB_SK, BASE_CIPHERTEXT, baseSignedMessage, '-k', off('a51-kl.txt')]) + }) + it('fails loudly rather than reporting success it did not achieve', () => { + // The file branch unwraps the array and hands dilithium the message; the JSON + // branch validates entry [0] and then hands dilithium the whole array, which + // throws. That throw used to happen in a callback nothing awaited, so the + // command printed 'keys generated!', wrote no key list, and exited zero. + assert.ok(result.out.includes('Shared secrets found, decrypting and generating shared keylist'), result.out) + assert.ok(/Cannot pass non-string to std::string/.test(result.out), result.out) + assert.ok(!result.out.includes('keys generated!'), result.out) + assert.strictEqual(fs.existsSync(off('a51-kl.txt')), false) + assert.notStrictEqual(result.code, 0) + }) + }) + + // ------------------------------------------------------------------------- + // Unreadable inputs + // ------------------------------------------------------------------------- + describe('generate-shared-keys #52 - whitespace only files', () => { + const results = {} + before(async function whitespaceFiles() { + this.timeout(200000) + results.secret = await runOffline([ + BOB_PUB, WHITESPACE, + '-c', off('a52-ct.txt'), '-s', off('a52-sm.txt'), '-k', off('a52-kl.txt'), + ]) + results.cypher = await runOffline([ALICE_PUB, BOB_SK, WHITESPACE, BASE_SIGNED, '-k', off('a52-kl.txt')]) + results.signed = await runOffline([ALICE_PUB, BOB_SK, BASE_CIPHERTEXT, WHITESPACE, '-k', off('a52-kl.txt')]) + }) + // The emptiness check is awaited, so it always reports before the JSON parse + // that would otherwise reject the same file as unreadable: each of these says + // the file is empty, which is the useful message of the two. + it('rejects a secret key file with nothing in it', () => { + assert.ok(/File is empty/.test(results.secret.out), results.secret.out) + assert.notStrictEqual(results.secret.code, 0) + }) + it('rejects a cyphertext file with nothing in it', () => { + assert.ok(/Ciphertext File is empty/.test(results.cypher.out), results.cypher.out) + assert.notStrictEqual(results.cypher.code, 0) + }) + it('rejects a signed message file with nothing in it', () => { + assert.ok(/signedMessage File is empty/.test(results.signed.out), results.signed.out) + assert.notStrictEqual(results.signed.code, 0) + }) + }) + + describe('generate-shared-keys #53 - secret keys that are neither file nor JSON', () => { + let unreadableFile + let notJson + before(async function unreadableSecrets() { + this.timeout(200000) + unreadableFile = await runOffline([ + BOB_PUB, NOT_JSON, + '-c', off('a53-ct.txt'), '-s', off('a53-sm.txt'), '-k', off('a53-kl.txt'), + ]) + notJson = await runOffline([ + BOB_PUB, 'still not json', + '-c', off('a53-ct.txt'), '-s', off('a53-sm.txt'), '-k', off('a53-kl.txt'), + ]) + }) + it('reports a file it cannot parse', () => { + assert.ok(unreadableFile.out.includes('Unable to open file'), unreadableFile.out) + assert.notStrictEqual(unreadableFile.code, 0) + }) + it('reports an argument that is not JSON either', () => { + assert.ok(notJson.out.includes('Invalid JSON given'), notJson.out) + assert.notStrictEqual(notJson.code, 0) + }) + }) + + describe('generate-shared-keys #56 - public keys that are neither file, hash nor JSON', () => { + let result + before(async function publicKeysNotJson() { + this.timeout(120000) + result = await runOffline([ + 'not a file, not a hash, not json', ALICE_SK, + '-c', off('a56-ct.txt'), '-s', off('a56-sm.txt'), '-k', off('a56-kl.txt'), + ]) + }) + it('reports the public keys are unusable', () => { + assert.ok(result.out.includes('not valid json or json file given'), result.out) + assert.notStrictEqual(result.code, 0) + }) + }) + describe('generate-shared-keys #54 - public key file missing its header entry', () => { + let noAddress + let noNetwork + before(async function badPublicHeader() { + this.timeout(200000) + const keys = readJson(BOB_PUB) + const run = (header) => + runOffline([ + JSON.stringify([header, keys[1]]), ALICE_SK, + '-c', off('a54-ct.txt'), '-s', off('a54-sm.txt'), '-k', off('a54-kl.txt'), + ]) + noAddress = await run({network: 'Testnet'}) + noNetwork = await run({address: 'Q040506'}) + }) + it('rejects public keys with no address', () => { + assert.ok(noAddress.out.includes('Output #0 does not have a address'), noAddress.out) + assert.notStrictEqual(noAddress.code, 0) + }) + it('rejects public keys with no network', () => { + assert.ok(noNetwork.out.includes('Output #0 does not have a network'), noNetwork.out) + assert.notStrictEqual(noNetwork.code, 0) + }) + }) + + describe('generate-shared-keys #55 - a directory where a key file should be', () => { + let result + before(async function directoryAsKeyFile() { + this.timeout(120000) + result = await runOffline([ + BOB_PUB, OFFLINE, + '-c', off('a55-ct.txt'), '-s', off('a55-sm.txt'), '-k', off('a55-kl.txt'), + ]) + }) + it('reports it cannot read the directory', () => { + // fs.existsSync says yes to a directory, so the emptiness check is the first + // thing that actually reads it. It is awaited now, so its EISDIR rejection is + // reported instead of racing the JSON parse that follows. + assert.ok(/EISDIR: illegal operation on a directory/.test(result.out), result.out) + assert.notStrictEqual(result.code, 0) + }) + + it('writes no key list', () => { + assert.strictEqual(fs.existsSync(off('a55-kl.txt')), false) + }) + }) +}) diff --git a/test/commands/get-keys.test.js b/test/commands/get-keys.test.js index 0fbc2ba..c03ef70 100644 --- a/test/commands/get-keys.test.js +++ b/test/commands/get-keys.test.js @@ -6,6 +6,8 @@ const assert = require('assert') const {spawn} = require('child_process') const fs = require('fs') +const path = require('path') + const setup = require('../test_setup') // Suites needing on-chain state (skipped in offline mock mode: hooks create no broadcast txs) @@ -423,4 +425,278 @@ describe('get-keys #18', () => { it('exit code should be 0 if everything is correct with keys printed to file', () => { assert.strictEqual(exitCode, 0) }) -}) \ No newline at end of file +}) +// /////////////////////////////////////////////////////////////////////////// +// Offline cases +// +// The suites above all reach for a real node: most of them assert exit code 0, +// which only holds while mainnet/testnet are answering. The cases below run +// with no node at all -- each one stops at a validation gate, or at a +// connection to a closed local port -- and they assert on the message rather +// than the exit code alone, so a command that starts failing at a different +// gate cannot keep passing. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback: the CLI resolves it, fails to connect, and exits. +// Deterministic, and it never leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +// Well formed but arbitrary: these never get as far as a node. +const SOME_ADDRESS = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f3' +const SOME_TX_HASH = '021bb526ec6d35e880e2e706e2dd16a4c6da7223a8b632a57cd5cd44d5f4cf42' + +// -t and -m hard-code the public QRL endpoints and ignore --grpc, so the only +// way to exercise them without reaching mainnet or testnet is to take name +// resolution away from the child, which is what a machine with no network +// looks like anyway. Appended to NODE_OPTIONS so nyc's own preload survives. +const OFFLINE_DNS = path.join(__dirname, '..', 'helpers', 'offline-dns.js') +const offlineEnv = { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --require ${OFFLINE_DNS}`.trim(), +} + +function runGetKeys(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', ['get-keys', ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + env: offlineEnv, + }) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +describe('get-keys offline', () => { + it('says which argument is missing when given none', async () => { + const {code, out} = await runGetKeys([]) + assert.notStrictEqual(code, 0) + assert.ok(/No address or txHash given/.test(out), out) + }) + + it('names the address as the problem when it fails validation', async () => { + const {code, out} = await runGetKeys(['-a', 'Q0000000000000000000000000000000000000000000000000000000000000000000000000000000']) + assert.notStrictEqual(code, 0) + assert.ok(/QRL Address is not valid/.test(out), out) + }) + + it('rejects a non-numeric items-per-page', async () => { + const {code, out} = await runGetKeys(['-a', SOME_ADDRESS, '-i', 'lots']) + assert.notStrictEqual(code, 0) + assert.ok(/Not a valid number: Need items per page number/.test(out), out) + }) + + it('rejects a non-numeric page number', async () => { + const {code, out} = await runGetKeys(['-a', SOME_ADDRESS, '-i', '1', '-p', 'first']) + assert.notStrictEqual(code, 0) + assert.ok(/Not a valid number: Which page to view/.test(out), out) + }) + + it('reports the endpoint it was asked to use before it fails to reach it', async () => { + const {code, out} = await runGetKeys(['-a', SOME_ADDRESS, '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(out.includes(`Custom GRPC endpoint: [${DEAD_NODE}]`), out) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('names testnet as the network it is querying', async () => { + const {code, out} = await runGetKeys(['-a', SOME_ADDRESS, '-t']) + assert.notStrictEqual(code, 0) + assert.ok(/Fetching Lattice keys on/.test(out), out) + assert.ok(/Testnet/.test(out), out) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('names mainnet as the network it is querying', async () => { + const {code, out} = await runGetKeys(['-a', SOME_ADDRESS, '-m']) + assert.notStrictEqual(code, 0) + assert.ok(/Mainnet/.test(out), out) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('fails the same way for a transaction hash lookup', async () => { + const {code, out} = await runGetKeys(['-T', SOME_TX_HASH, '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), out) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// get-keys: what the command does once a node has answered +// +// The suites above stop at a closed loopback port, so everything after the +// connection - the retry loop and the transaction lookup - only ran against a +// live node. These cases run the command in *this* process against a stub gRPC +// client instead. src/functions/grpc is swapped in the require cache for the +// moment it takes to require the command (it captures Qrlnode at require time), +// then the real module is put straight back. There is no server and no socket, +// and `get-keys` only ever reads. +// /////////////////////////////////////////////////////////////////////////// + +// kleur colours by environment variable rather than by isTTY, so captured output +// still carries escape sequences. Strip them before matching. +const GK_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +// Behaviour the stub should show for the test currently running. Reset per test. +let gkNode = {} +let gkCalls = [] +let gkConnectAttempts = 0 + +class GetKeysFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + gkConnectAttempts += 1 + if (gkNode.connectThrows) { + throw new Error(gkNode.connectThrows) + } + const connectsOn = gkNode.connectsOnAttempt === undefined ? 1 : gkNode.connectsOnAttempt + if (connectsOn !== 0 && gkConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + gkCalls.push({name, request}) + if (name === 'GetTransaction') { + return gkNode.transaction + } + return gkNode.object === undefined ? {found: false} : gkNode.object + } +} + +const gkGrpcPath = require.resolve('../../src/functions/grpc') +const gkCommandPath = require.resolve('../../src/commands/get-keys') + +const gkRealGrpcEntry = require.cache[gkGrpcPath] +require.cache[gkGrpcPath] = { + id: gkGrpcPath, + filename: gkGrpcPath, + path: path.dirname(gkGrpcPath), + loaded: true, + children: [], + paths: [], + exports: GetKeysFakeQrlNode, +} +const {keySearch} = require('../../src/commands/get-keys') + +if (gkRealGrpcEntry) { + require.cache[gkGrpcPath] = gkRealGrpcEntry +} else { + delete require.cache[gkGrpcPath] +} + +async function runGetKeysOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = (chunk) => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await keySearch.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + return {code, out: chunks.join('').replace(GK_ANSI, '')} +} + +const gkBytes = (hex) => Buffer.from(hex, 'hex') + +// A lattice transaction as the node returns it, with the three public keys the +// command is there to extract. +const LATTICE_OBJECT = { + found: true, + transaction: { + addr_from: gkBytes(SOME_ADDRESS.substring(1)), + tx: { + transaction_hash: gkBytes(SOME_TX_HASH), + latticePK: { + pk1: gkBytes('aa'.repeat(32)), + pk2: gkBytes('bb'.repeat(32)), + pk3: gkBytes('cc'.repeat(32)), + }, + }, + }, +} + +const LATTICE_METADATA = { + tx: { + master_addr: gkBytes(''), + public_key: gkBytes('dd'.repeat(32)), + signature: gkBytes('ee'.repeat(32)), + transaction_hash: gkBytes(SOME_TX_HASH), + }, + block_header_hash: gkBytes('ff'.repeat(32)), +} + +describe('get-keys: reading a node reply', () => { + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[gkCommandPath] + }) + + beforeEach(() => { + gkNode = {object: LATTICE_OBJECT, transaction: LATTICE_METADATA} + gkCalls = [] + gkConnectAttempts = 0 + }) + + it('retries the connection until the node answers', async () => { + gkNode = {connectsOnAttempt: 3, object: {found: false}} + const {code, out} = await runGetKeysOffline(['-T', SOME_TX_HASH, '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.strictEqual(gkConnectAttempts, 3) + assert.ok(/retry connection attempt: 0/.test(out), out) + }) + + it('asks the node for the transaction hash it was given, as bytes', async () => { + const {code, out} = await runGetKeysOffline(['-T', SOME_TX_HASH, '-g', DEAD_NODE]) + assert.strictEqual(code, 0, out) + const lookup = gkCalls.find((c) => c.name === 'GetObject') + assert.strictEqual(lookup.request.query.toString('hex'), SOME_TX_HASH) + }) + + it('reports the three lattice public keys it found', async () => { + const {code, out} = await runGetKeysOffline(['-T', SOME_TX_HASH, '-g', DEAD_NODE]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes('aa'.repeat(32)), out) + assert.ok(out.includes('bb'.repeat(32)), out) + assert.ok(out.includes('cc'.repeat(32)), out) + }) + + it('exits non-zero when the node has no such transaction', async () => { + gkNode = {object: {found: false}} + const {code, out} = await runGetKeysOffline(['-T', SOME_TX_HASH, '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to find transaction/.test(out), out) + }) + + it('refuses a transaction that is not a lattice transaction', async () => { + // Without this check the command would go on to read latticePK off a + // transaction that has none, and report keys it never found. + gkNode = {object: {found: true, transaction: {addr_from: gkBytes(SOME_ADDRESS.substring(1)), tx: {}}}} + const {code, out} = await runGetKeysOffline(['-T', SOME_TX_HASH, '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.ok(/Not a lattice transaction/.test(out), out) + }) +}) diff --git a/test/commands/list-transactions.test.js b/test/commands/list-transactions.test.js index 0a1768c..2834ad7 100644 --- a/test/commands/list-transactions.test.js +++ b/test/commands/list-transactions.test.js @@ -1,9 +1,15 @@ const assert = require('assert') const {spawn} = require('child_process') +const crypto = require('crypto') const fs = require('fs') const testSetup = require('../test_setup') +// Suites that query a live QRL node (mainnet/testnet). Skipped in offline mode: the repo's +// existing pattern (see search.test.js / get-keys.test.js) so an offline pass never leaves the +// machine. The node-dependent code paths they cover are listed in the offline suite below. +const describeOnline = process.env.QRL_TEST_OFFLINE === 'true' ? describe.skip : describe + const processFlags = { stdio: 'pipe', // Changed from 'inherit' to 'pipe' to better control output } @@ -222,7 +228,7 @@ describe('list-transactions #7', () => { }) // mainnet list-transactions -describe('list-transactions #8', () => { +describeOnline('list-transactions #8', () => { let exitCode before(async function listTransactionsTest8() { this.timeout(60000) @@ -245,7 +251,7 @@ describe('list-transactions #8', () => { }) // success with quiet flag -describe('list-transactions #9', () => { +describeOnline('list-transactions #9', () => { let exitCode before(async function listTransactionsTest9() { this.timeout(60000) @@ -269,7 +275,7 @@ describe('list-transactions #9', () => { }) // success with limit flag -describe('list-transactions #10', () => { +describeOnline('list-transactions #10', () => { let exitCode before(async function listTransactionsTest10() { this.timeout(240000) // Increased timeout to account for API delays and multiple pages @@ -304,7 +310,7 @@ describe('list-transactions #10', () => { }) // success testnet -describe('list-transactions #11', () => { +describeOnline('list-transactions #11', () => { let exitCode before(async function listTransactionsTest11() { this.timeout(60000) @@ -324,7 +330,7 @@ describe('list-transactions #11', () => { }) // success mainnet -describe('list-transactions #12', () => { +describeOnline('list-transactions #12', () => { let exitCode before(async function listTransactionsTest12() { this.timeout(60000) @@ -348,7 +354,7 @@ describe('list-transactions #12', () => { }) // success wallet file -describe('list-transactions #13', () => { +describeOnline('list-transactions #13', () => { let exitCode before(async function listTransactionsTest13() { this.timeout(60000) @@ -377,7 +383,7 @@ describe('list-transactions #13', () => { }) // success enc-wallet file -describe('list-transactions #14', () => { +describeOnline('list-transactions #14', () => { let exitCode before(async function listTransactionsTest14() { this.timeout(60000) @@ -408,7 +414,7 @@ describe('list-transactions #14', () => { }) // success with CSV output -describe('list-transactions #15', () => { +describeOnline('list-transactions #15', () => { let exitCode before(async function listTransactionsTest15() { this.timeout(60000) @@ -430,4 +436,845 @@ describe('list-transactions #15', () => { it('exit code should be 0 if passed with a valid address and CSV output flag', () => { assert.strictEqual(exitCode, 0) }) -}) \ No newline at end of file +}) +// /////////////////////////////////////////////////////////////////////////// +// list-transactions: offline coverage suite +// +// This command is a read-only node query with a paginating fetch loop, so +// everything from "connected" onwards needs a node. What *is* reachable offline +// is the front half: STDIN input, address validation, wallet-file opening and +// decryption (including the legacy encryption format), the --json mode branches +// that suppress every spinner, and the connection-failure path. +// +// Every case here either stops at a validation gate or dies connecting to a +// closed loopback port. Nothing contacts mainnet or testnet. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback: resolves, refuses the connection, exits. Never leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +const LTX_WALLET = '/tmp/list-transactions-wallet.json' +// Pre-v2 (`aes256` package) encryption: unauthenticated, so a wrong password +// returns garbage instead of throwing. It is the only way to reach the +// "invalid password" branch, which validates the decrypted address. +const LTX_LEGACY_WALLET = '/tmp/list-transactions-wallet-legacy.json' +const LTX_BAD_WALLET = '/tmp/list-transactions-bad-wallet.json' +const LTX_NO_FLAG_WALLET = '/tmp/list-transactions-no-encrypted-flag-wallet.json' +const LTX_PASSWORD = 'testpassword' +const LTX_ADDRESS = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f3' + +// Run the CLI and capture what it said. `input`, when given, is written to STDIN. +function runLtx(args, input) { + return new Promise((resolve) => { + const child = spawn('./bin/run', args, {stdio: ['pipe', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', (d) => { + out += d.toString() + }) + child.stderr.on('data', (d) => { + out += d.toString() + }) + child.on('close', (code) => resolve({code, out})) + if (input !== undefined) { + child.stdin.write(input) + } + child.stdin.end() + }) +} + +function ltxRefuses(args, expected, input) { + return runLtx(args, input).then(({code, out}) => { + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}\n--- output ---\n${out}`) + assert.ok(expected.test(out), `expected output to match ${expected}\n--- actual ---\n${out}`) + }) +} + +function createLtxWallet(file, password) { + return new Promise((resolve, reject) => { + const args = ['create-wallet', '-h', '6', '-f', file] + if (password) { + args.push('-p', password) + } + const child = spawn('./bin/run', args, {stdio: ['ignore', 'ignore', 'ignore']}) + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`create-wallet exited ${code}`)))) + child.on('error', reject) + }) +} + +// Legacy `aes256` blob: key = sha256(password), AES-256-CTR, base64(iv || ciphertext). +function legacyEncryptLtx(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(String(password)).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]) + return Buffer.concat([iv, ciphertext]).toString('base64') +} + +describe('list-transactions: offline coverage', () => { + let plainWallet + + before(async function createLtxFixtures() { + this.timeout(120000) + await createLtxWallet(LTX_WALLET, null) + ;[plainWallet] = JSON.parse(fs.readFileSync(LTX_WALLET)) + + fs.writeFileSync( + LTX_LEGACY_WALLET, + JSON.stringify([ + { + encrypted: true, + address: legacyEncryptLtx(LTX_PASSWORD, plainWallet.address), + addressB32: legacyEncryptLtx(LTX_PASSWORD, plainWallet.addressB32), + pk: legacyEncryptLtx(LTX_PASSWORD, plainWallet.pk), + hexseed: legacyEncryptLtx(LTX_PASSWORD, plainWallet.hexseed), + mnemonic: legacyEncryptLtx(LTX_PASSWORD, plainWallet.mnemonic), + height: plainWallet.height, + hashFunction: plainWallet.hashFunction, + signatureType: plainWallet.signatureType, + index: plainWallet.index, + }, + ]) + ) + + // Valid JSON, but not a wallet: `JSON.parse(contents)[0]` is undefined, so + // reading `.encrypted` off it throws inside the command's try/catch. + fs.writeFileSync(LTX_BAD_WALLET, JSON.stringify({not: 'a wallet'})) + + // Shaped like a wallet but with no `encrypted` key: neither the plaintext nor the + // encrypted branch runs, so the file is never accepted and the command must say so + // rather than falling through and querying the filename as an address. + fs.writeFileSync(LTX_NO_FLAG_WALLET, JSON.stringify([{address: LTX_ADDRESS}])) + }) + + after(() => { + [LTX_WALLET, LTX_LEGACY_WALLET, LTX_BAD_WALLET, LTX_NO_FLAG_WALLET].forEach((file) => { + try { + fs.unlinkSync(file) + } catch (err) { + // fixture already gone; nothing to clean up + } + }) + }) + + describe('address input', () => { + it('reads the address from STDIN when the argument is "-"', async function stdinDash() { + this.timeout(60000) + await ltxRefuses(['list-transactions', '-', '-g', DEAD_NODE], /Failed to connect to node/, `${LTX_ADDRESS}\n`) + }) + + it('reads the address from STDIN when no argument is given and STDIN is a pipe', async function stdinPipe() { + this.timeout(60000) + await ltxRefuses(['list-transactions', '-g', DEAD_NODE], /Failed to connect to node/, `${LTX_ADDRESS}\n`) + }) + + it('explains what is missing when STDIN is a pipe carrying nothing', async function stdinEmpty() { + this.timeout(60000) + await ltxRefuses(['list-transactions'], /Missing QRL address or wallet file/, '') + }) + }) + + describe('wallet files', () => { + it('reads the address out of an unencrypted wallet file', async function plainWalletFile() { + this.timeout(60000) + const {code, out} = await runLtx(['list-transactions', LTX_WALLET, '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(out.includes(plainWallet.address), `expected the wallet address in the output\n--- actual ---\n${out}`) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + }) + + it('decrypts a legacy-format wallet file with the right password', async function legacyWalletFile() { + this.timeout(60000) + const {code, out} = await runLtx([ + 'list-transactions', + LTX_LEGACY_WALLET, + '-p', + LTX_PASSWORD, + '-g', + DEAD_NODE, + ]) + assert.notStrictEqual(code, 0) + assert.ok(out.includes(plainWallet.address), `expected the decrypted address in the output\n--- actual ---\n${out}`) + }) + + it('refuses a legacy-format wallet file when the password is wrong', async function legacyWalletBadPassword() { + this.timeout(60000) + await ltxRefuses( + ['list-transactions', LTX_LEGACY_WALLET, '-p', 'not-the-password'], + /Unable to open wallet file: invalid password/ + ) + }) + + it('refuses a wallet file with no "encrypted" key', async function noEncryptedFlag() { + this.timeout(60000) + await ltxRefuses( + ['list-transactions', LTX_NO_FLAG_WALLET], + /Unable to list transactions: invalid QRL address\/wallet file/ + ) + }) + + it('reports a decryption error for a JSON file that is not a wallet', async function notAWallet() { + this.timeout(60000) + await ltxRefuses(['list-transactions', LTX_BAD_WALLET], /Error decrypting wallet/) + }) + }) + + describe('--json output mode', () => { + it('suppresses the spinner and reports the connection failure as a log line', async function jsonConnectFailure() { + this.timeout(60000) + const {code, out} = await runLtx(['list-transactions', LTX_ADDRESS, '-j', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + // The human banner and address header must not pollute JSON mode + assert.ok(!/Custom GRPC endpoint/.test(out), `--- actual ---\n${out}`) + assert.ok(!/Address:/.test(out), `--- actual ---\n${out}`) + }) + + it('does not print the wallet address banner in --json mode', async function jsonWalletFile() { + this.timeout(60000) + const {code, out} = await runLtx(['list-transactions', LTX_WALLET, '-j', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + }) + }) + + describe('node connection', () => { + it('fails with a spinner message when the node is unreachable', async function deadNode() { + this.timeout(60000) + await ltxRefuses(['list-transactions', LTX_ADDRESS, '-g', DEAD_NODE], /Failed to connect to node/) + }) + + it('still fails on an unreachable node when --limit and --csv are given', async function deadNodeWithFlags() { + this.timeout(60000) + // --csv only writes after a successful fetch, so an unreachable node must not + // leave a partial file behind. + const csvPath = '/tmp/list-transactions-offline.csv' + try { + fs.unlinkSync(csvPath) + } catch (err) { + // no file to remove + } + await ltxRefuses( + ['list-transactions', LTX_ADDRESS, '--limit', '10', '--csv', csvPath, '-g', DEAD_NODE], + /Failed to connect to node/ + ) + assert.strictEqual(fs.existsSync(csvPath), false, 'no CSV file may be written when the fetch never happened') + }) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// list-transactions: node-dependent coverage +// +// The suite above stops wherever a node would be needed. This one covers the +// other half - the address-state query, the paginating fetch loop, the console +// table, the CSV writer and --json mode - by running the command in *this* +// process against a stub gRPC client. +// +// src/functions/grpc is swapped in the require cache for the single moment it +// takes to require the command (the command captures Qrlnode at require time), +// then the real module is put straight back. There is no server and no socket, +// so nothing here leaves the machine and no OTS key is consumed: this command +// only ever reads. +// /////////////////////////////////////////////////////////////////////////// + +// Behaviour the stub should show for the test currently running. Reset per test. +let ltxNode = {} + +// What the command asked the node for, so the tests can assert on page numbers +// and page size rather than just on the printed output. +let ltxRequests = [] + +// How many times the command called connect(): the retry loop only rewrites +// spinner text, which prints nothing when stderr is not a terminal. +let ltxConnectAttempts = 0 + +class LtxFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + ltxConnectAttempts += 1 + if (ltxNode.connectThrows) { + throw new Error(ltxNode.connectThrows) + } + // connectsOnAttempt of 0 means "never connects", which is what the real client + // does when the node answers but fails the proto hash check. + const connectsOn = ltxNode.connectsOnAttempt === undefined ? 1 : ltxNode.connectsOnAttempt + if (connectsOn !== 0 && ltxConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + ltxRequests.push({name, request}) + if (name === 'GetOptimizedAddressState') { + if (ltxNode.stateThrows) { + throw new Error(ltxNode.stateThrows) + } + return ltxNode.state === undefined ? {} : ltxNode.state + } + if (ltxNode.apiThrows) { + throw new Error(ltxNode.apiThrows) + } + const page = (ltxNode.pages || [])[request.page_number - 1] || [] + return {transactions_detail: page} + } +} + +const ltxGrpcPath = require.resolve('../../src/functions/grpc') +const ltxCommandPath = require.resolve('../../src/commands/list-transactions') + +const ltxRealGrpcEntry = require.cache[ltxGrpcPath] +require.cache[ltxGrpcPath] = { + id: ltxGrpcPath, + filename: ltxGrpcPath, + path: require('path').dirname(ltxGrpcPath), // eslint-disable-line global-require + loaded: true, + children: [], + paths: [], + exports: LtxFakeQrlNode, +} +const {ListTransactions} = require('../../src/commands/list-transactions') + +if (ltxRealGrpcEntry) { + require.cache[ltxGrpcPath] = ltxRealGrpcEntry +} else { + delete require.cache[ltxGrpcPath] +} + +// Run the command here, against the stub, capturing everything it prints +// (this.log goes to stdout, the ora spinners go to stderr). +async function runLtxOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = (chunk) => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await ListTransactions.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + // kleur colours by environment variable, not by isTTY, so the captured output + // still carries escape sequences here. Strip them so the assertions read as + // the text a person would see. + // eslint-disable-next-line no-control-regex + return {code, out: chunks.join('').replace(/\u001B\[[0-9;]*m/g, '')} +} + +const ltxHex = (hex) => Buffer.from(hex, 'hex') + +// The address every test queries for, and a second one to be the other party. +const LTX_QUERY_HEX = LTX_ADDRESS.substring(1) +const LTX_OTHER_HEX = '000200ecffb27f3d7b11ccd048eb559277d64bb52bfda998341e66a9f11b2d07f6b2ee4f62c408' +const LTX_OTHER_ADDRESS = `Q${LTX_OTHER_HEX}` + +// A GetTransactionsByAddress entry, shaped the way the node returns it. +const ltxTx = (body, extra = {}) => ({ + addr_from: ltxHex(LTX_QUERY_HEX), + timestamp: 1600000000, + block_number: 12345, + tx: {transaction_hash: ltxHex('ab'.repeat(32)), fee: '100000000', ...body}, + ...extra, +}) + +// Sent by the queried address: direction OUT. +const LTX_TRANSFER_OUT = ltxTx({ + transactionType: 'transfer', + transfer: {amounts: ['1500000000'], addrs_to: [ltxHex(LTX_OTHER_HEX)]}, +}) + +// Mined to the queried address: direction IN, via the coinbase branch. +const LTX_COINBASE_IN = ltxTx( + { + transactionType: 'coinbase', + coinbase: {amount: '2000000000', addr_to: ltxHex(LTX_QUERY_HEX)}, + }, + {addr_from: ltxHex(LTX_OTHER_HEX)} +) + +// Neither sent nor received by the queried address: direction MISC. The comma in +// the type also exercises the CSV quoting path. +const LTX_TOKEN_MISC = ltxTx( + { + transactionType: 'transfer_token,v2', + transfer_token: {amounts: ['42'], addrs_to: [ltxHex(LTX_OTHER_HEX)]}, + }, + {addr_from: ltxHex(LTX_OTHER_HEX)} +) + +// No timestamp, no block, no transaction body and no type: every field has to +// fall back rather than throw. +const LTX_BARE = {addr_from: undefined, tx: undefined} + +// A transfer to someone else that also carries a coinbase paid to the queried +// address. `to` is read off the transfer, so the only thing that can still call +// this IN is the coinbase half of the direction check - which is exactly why +// that half is there. +const LTX_COINBASE_BEHIND_TRANSFER = ltxTx( + { + transactionType: 'transfer', + transfer: {amounts: ['1000000000'], addrs_to: [ltxHex(LTX_OTHER_HEX)]}, + coinbase: {amount: '2000000000', addr_to: ltxHex(LTX_QUERY_HEX)}, + }, + {addr_from: ltxHex(LTX_OTHER_HEX)} +) + +// The address prompt and the wallet-password prompt only run when stdin and +// stdout are terminals, so through a pipe that whole branch is unreachable. Fake +// the terminal, and stand in for the two prompt libraries the command uses: +// `prompts` (required lazily inside run()) and cli-ux's `cli.prompt`. +const ltxCliUx = require('cli-ux').cli // eslint-disable-line import/order + +function stubLtxPrompts(fake) { + const promptsPath = require.resolve('prompts') + const saved = require.cache[promptsPath] + const Module = require('module') // eslint-disable-line global-require + const stub = new Module(promptsPath, null) + stub.filename = promptsPath + stub.loaded = true + stub.exports = fake + require.cache[promptsPath] = stub + return () => { + if (saved === undefined) { + delete require.cache[promptsPath] + } else { + require.cache[promptsPath] = saved + } + } +} + +// cli-ux exposes `prompt` as a getter, so it has to be redefined rather than assigned. +function stubLtxPassword(password) { + const saved = Object.getOwnPropertyDescriptor(ltxCliUx, 'prompt') + const asked = [] + Object.defineProperty(ltxCliUx, 'prompt', { + configurable: true, + get: () => async (message, options) => { + asked.push({message, options}) + return password + }, + }) + return {asked, restore: () => Object.defineProperty(ltxCliUx, 'prompt', saved)} +} + +async function runLtxInteractive(argv, {fakePrompts, fakePassword} = {}) { + const restorePrompts = stubLtxPrompts(fakePrompts || (async () => ({}))) + const password = fakePassword === undefined ? null : stubLtxPassword(fakePassword) + const savedStdout = process.stdout.isTTY + const savedStdin = process.stdin.isTTY + process.stdout.isTTY = true + process.stdin.isTTY = true + try { + const result = await runLtxOffline(argv) + return {...result, asked: password ? password.asked : []} + } finally { + process.stdout.isTTY = savedStdout + process.stdin.isTTY = savedStdin + if (password) { + password.restore() + } + restorePrompts() + } +} + +describe('list-transactions: node-dependent coverage', () => { + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[ltxCommandPath] + }) + + beforeEach(() => { + ltxNode = {pages: [[]]} + ltxRequests = [] + ltxConnectAttempts = 0 + }) + + describe('connecting', () => { + it('retries five times, then gives up on a node that never connects', async () => { + ltxNode = {connectsOnAttempt: 0, pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.strictEqual(ltxConnectAttempts, 6, 'one attempt plus five retries') + assert.strictEqual(ltxRequests.length, 0, 'nothing may be asked of a node that never connected') + }) + + it('gives up on a node that never connects in --json mode too', async () => { + // No spinner to fail, so the give-up path takes its other arm; it still has + // to exit non-zero rather than print an empty result. + ltxNode = {connectsOnAttempt: 0, pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.strictEqual(ltxConnectAttempts, 6, 'one attempt plus five retries') + assert.strictEqual(out.trim(), '', 'nothing may reach stdout when there is no result') + }) + + it('recovers when the node answers on a later retry', async () => { + ltxNode = {connectsOnAttempt: 3, state: {}, pages: [[]]} + const {code} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0) + assert.strictEqual(ltxConnectAttempts, 3) + }) + + it('retries quietly in --json mode, where there is no spinner to update', async () => { + ltxNode = {connectsOnAttempt: 3, state: {}, pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(ltxConnectAttempts, 3) + assert.ok(!/retry connection attempt/.test(out), out) + assert.strictEqual(out.trim(), '[]') + }) + + it('reports a connect() error as a plain log line in --json mode', async () => { + ltxNode = {connectThrows: 'no route to host'} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to connect to node: Error: no route to host/.test(out), out) + }) + }) + + describe('address state', () => { + it('turns the transaction count into a page estimate', async function stateCount() { + this.timeout(60000) + ltxNode = {state: {state: {transaction_hash_count: '3'}}, pages: [[LTX_TRANSFER_OUT, LTX_COINBASE_IN], [LTX_TOKEN_MISC]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-l', '2', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Found 3 transactions \(2 pages\)/.test(out), out) + assert.deepStrictEqual( + ltxRequests.filter((r) => r.name === 'GetTransactionsByAddress').map((r) => r.request.page_number), + [1, 2] + ) + assert.ok(/3 total transactions/.test(out), out) + }) + + it('stops before fetching when the address has no transactions', async () => { + ltxNode = {state: {state: {transaction_hash_count: '0'}}} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes(`No transactions found for address ${LTX_ADDRESS}`), out) + assert.strictEqual( + ltxRequests.filter((r) => r.name === 'GetTransactionsByAddress').length, + 0, + 'a zero count must short-circuit the fetch loop' + ) + }) + + it('prints an empty JSON array when the address has no transactions', async () => { + ltxNode = {state: {state: {transaction_hash_count: '0'}}} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(out.trim(), '[]', out) + }) + + it('turns the transaction count into a page estimate in --json mode too', async () => { + // Same reply, no spinner to report it through: the counts still have to drive + // the fetch loop rather than being skipped along with the progress output. + ltxNode = {state: {state: {transaction_hash_count: '1'}}, pages: [[LTX_TRANSFER_OUT]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', '-l', '2', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(JSON.parse(out).length, 1) + assert.ok(!/Found 1 transactions/.test(out), out) + }) + + it('warns silently when the state query fails in --json mode', async () => { + ltxNode = {stateThrows: 'state unavailable', pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(!/Could not get address state/.test(out), out) + assert.strictEqual(out.trim(), '[]') + }) + + it('warns and carries on when the state query fails', async () => { + ltxNode = {stateThrows: 'state unavailable', pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Could not get address state \(state unavailable\) - continuing with fetch/.test(out), out) + }) + + it('says the count is unknown when the node returns no state', async () => { + ltxNode = {state: {}, pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/transaction count will be determined during fetch/.test(out), out) + }) + }) + + describe('fetch loop', () => { + it('reports an empty first page as no transactions', async () => { + ltxNode = {state: {}, pages: [[]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/No transactions found for this address/.test(out), out) + assert.ok(out.includes(`No transactions found for address ${LTX_ADDRESS}`), out) + }) + + it('estimates the total while paging when the count is unknown', async function estimating() { + // Two full pages then an empty one, with a 5 second rate-limit pause after + // each: deliberately slow rather than flaky. + this.timeout(90000) + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT], [LTX_COINBASE_IN], []]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-l', '1', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/est\. 3\+/.test(out), `expected a first-page estimate\n--- actual ---\n${out}`) + assert.ok(/Pausing/.test(out), out) + assert.ok(/End of data/.test(out), out) + assert.ok(/2 total transactions/.test(out), out) + }) + + it('exits when the node errors on a page', async () => { + ltxNode = {state: {}, apiThrows: 'stream removed', pages: []} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Page 1 │ stream removed/.test(out), out) + }) + + it('reports a page error as a plain log line in --json mode', async () => { + // No spinner to fail in JSON mode, so the error takes the other branch: a + // silent non-zero exit would be indistinguishable from an empty result. + ltxNode = {state: {}, apiThrows: 'stream removed', pages: []} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Error fetching transactions page 1: stream removed/.test(out), out) + }) + + it('falls back to 100 per page when --limit is zero', async () => { + // oclif defaults --limit to 100, but an explicit 0 is falsy and gets through. + // Both the request and the page estimate have to fall back to the same number, + // or the command asks for zero-length pages and never finishes. + ltxNode = {state: {state: {transaction_hash_count: '3'}}, pages: [[LTX_TRANSFER_OUT]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-l', '0', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + const fetch = ltxRequests.find((r) => r.name === 'GetTransactionsByAddress') + assert.strictEqual(fetch.request.item_per_page, 100) + assert.ok(/Found 3 transactions \(1 pages\)/.test(out), out) + }) + + it('sends the address as bytes and honours --limit', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT]]} + await runLtxOffline(['-g', DEAD_NODE, '-l', '25', LTX_ADDRESS]) + const fetch = ltxRequests.find((r) => r.name === 'GetTransactionsByAddress') + assert.strictEqual(fetch.request.item_per_page, 25) + assert.strictEqual(fetch.request.address.toString('hex'), LTX_QUERY_HEX) + }) + }) + + describe('console output', () => { + it('lays out one row per transaction with direction, amount and fee', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT, LTX_COINBASE_IN, LTX_TOKEN_MISC, LTX_BARE]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction Summary/.test(out), out) + // transfer sent by the queried address + assert.ok(/OUT\s+transfer\s+/.test(out), out) + assert.ok(out.includes('1.500000000'), 'transfer amount in quanta') + // coinbase paid to the queried address + assert.ok(/IN\s+coinbase\s+/.test(out), out) + assert.ok(out.includes('2.000000000'), 'coinbase amount in quanta') + // token transfer between two other addresses + assert.ok(/MISC\s+transfer_token/.test(out), out) + assert.ok(out.includes('42 tokens'), out) + // the bodyless entry falls back rather than throwing + assert.ok(/N\/A\s+MISC\s+unknown/.test(out), out) + assert.ok(out.includes('0.100000000'), 'fee in quanta') + assert.ok(/4 total transactions/.test(out), out) + }) + + it('calls a coinbase paid to the queried address IN, whatever the transfer says', async () => { + ltxNode = {state: {}, pages: [[LTX_COINBASE_BEHIND_TRANSFER]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/IN\s+transfer\s+/.test(out), out) + }) + + it('keeps the console table when only --quiet is given', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT]]} + const {out} = await runLtxOffline(['-g', DEAD_NODE, '-q', LTX_ADDRESS]) + assert.ok(/Transaction Summary/.test(out), '--quiet alone only matters alongside --csv') + }) + }) + + describe('--csv export', () => { + const csvPath = '/tmp/list-transactions-node-coverage.csv' + + afterEach(() => { + try { + fs.unlinkSync(csvPath) + } catch (err) { + // no file to remove + } + }) + + it('writes a header row and one row per transaction, quoting embedded commas', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT, LTX_COINBASE_IN, LTX_TOKEN_MISC, LTX_BARE]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-c', csvPath, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes(`Transactions exported to CSV file: ${csvPath}`), out) + + const rows = fs.readFileSync(csvPath, 'utf8').split('\n') + assert.strictEqual(rows[0], 'Timestamp,Direction,Type,Hash,From,To,Amount,Fee,Block') + assert.strictEqual(rows.length, 5, 'header plus four transactions') + assert.ok( + rows[1].includes(`,OUT,transfer,`) && + rows[1].includes(`,Q${LTX_QUERY_HEX},${LTX_OTHER_ADDRESS},1.500000000,0.100000000,12345`), + rows[1] + ) + // a coinbase is credited to the queried address, and its amount is in quanta + assert.ok(rows[2].includes(',IN,coinbase,'), rows[2]) + assert.ok(rows[2].includes(',2.000000000,'), rows[2]) + // the type contains a comma, so the field has to be quoted + assert.ok(rows[3].includes('"transfer_token,v2"'), rows[3]) + // an entry with no timestamp, block or body still produces a row rather than throwing + assert.strictEqual(rows[4], 'N/A,MISC,unknown,N/A,N/A,N/A,0,0.000000000,N/A') + }) + + it('calls a coinbase paid to the queried address IN, whatever the transfer says', async () => { + ltxNode = {state: {}, pages: [[LTX_COINBASE_BEHIND_TRANSFER]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-c', csvPath, LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + const rows = fs.readFileSync(csvPath, 'utf8').split('\n') + assert.ok(rows[1].includes(',IN,transfer,'), rows[1]) + }) + + it('drops the console table when --csv and --quiet are used together', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-c', csvPath, '-q', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(!/Transaction Summary/.test(out), `--csv --quiet must not print the table\n--- actual ---\n${out}`) + assert.ok(fs.existsSync(csvPath), 'the CSV file is still written') + }) + + it('exits when the CSV file cannot be written', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT]]} + const {code, out} = await runLtxOffline([ + '-g', + DEAD_NODE, + '-c', + '/tmp/no-such-directory-for-qrl-cli/out.csv', + LTX_ADDRESS, + ]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to write CSV file/.test(out), out) + }) + }) + + describe('--json output', () => { + it('prints the transactions as JSON and exits 0', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + const parsed = JSON.parse(out) + assert.strictEqual(parsed.length, 1) + assert.strictEqual(parsed[0].block_number, 12345) + // none of the human-readable furniture may reach stdout in JSON mode + assert.ok(!/Transaction Summary/.test(out), out) + assert.ok(!/Fetching Transactions/.test(out), out) + }) + + it('stops on a short page with no spinner to tell it to', async () => { + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT]]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', '-l', '2', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(JSON.parse(out).length, 1) + assert.strictEqual(ltxRequests.filter((r) => r.name === 'GetTransactionsByAddress').length, 1) + }) + + it('pages past a full page with no spinner to tell it to', async function jsonPaging() { + this.timeout(60000) + ltxNode = {state: {}, pages: [[LTX_TRANSFER_OUT], []]} + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '-j', '-l', '1', LTX_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(JSON.parse(out).length, 1) + assert.deepStrictEqual( + ltxRequests.filter((r) => r.name === 'GetTransactionsByAddress').map((r) => r.request.page_number), + [1, 2] + ) + }) + }) + it('reports a directory where a wallet file was expected, rather than crashing', async () => { + const {code, out} = await runLtxOffline(['-g', DEAD_NODE, '/tmp']) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to list transactions: invalid QRL address\/wallet file/.test(out), out) + assert.strictEqual(ltxRequests.length, 0, 'no node is queried for a path that never opened') + }) + + describe('at a terminal', () => { + const promptWallet = '/tmp/list-transactions-node-prompt-wallet.json' + + before(() => { + // Decrypts with the right password to a real address, so the command gets + // past the address check and on to the (stubbed) node. + const aes = require('../../src/utils/aes') // eslint-disable-line global-require + fs.writeFileSync( + promptWallet, + JSON.stringify([{encrypted: true, address: aes.encrypt('prompted-password', LTX_ADDRESS)}]) + ) + }) + + after(() => { + try { + fs.unlinkSync(promptWallet) + } catch (err) { + // never created; nothing to clean up + } + }) + + it('asks for an address when none was given', async () => { + ltxNode = {state: {}, pages: [[]]} + let asked + const {code, out} = await runLtxInteractive(['-g', DEAD_NODE], { + fakePrompts: async options => { + asked = options + return {address: LTX_ADDRESS} + }, + }) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.name, 'address') + assert.ok(/QRL address or path to wallet\.json/.test(asked.message)) + assert.strictEqual(asked.validate(''), 'Address/File is required') + assert.strictEqual(asked.validate(LTX_ADDRESS), true) + }) + + it('exits non-zero when the address prompt is cancelled', async () => { + const {code, out} = await runLtxInteractive(['-g', DEAD_NODE], {fakePrompts: async () => ({})}) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.strictEqual(ltxRequests.length, 0, 'no node is queried without an address') + }) + + it('asks for the wallet password when --password is not given', async () => { + // Without this branch an encrypted wallet would only be usable with the + // password on the command line, where it lands in shell history. + ltxNode = {state: {}, pages: [[]]} + const {code, out, asked} = await runLtxInteractive([promptWallet, '-g', DEAD_NODE], { + fakePassword: 'prompted-password', + }) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.length, 1) + assert.ok(/Enter password for wallet file/.test(asked[0].message)) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + assert.ok(out.includes(LTX_ADDRESS), out) + }) + + it('refuses a wrong password typed at the wallet prompt', async () => { + const {code, out} = await runLtxInteractive([promptWallet, '-g', DEAD_NODE], { + fakePassword: 'not-the-password', + }) + assert.strictEqual(code, 1, out) + assert.strictEqual(ltxRequests.length, 0, 'no node is queried for an address that never decrypted') + }) + }) +}) diff --git a/test/commands/notarize.test.js b/test/commands/notarize.test.js index 5b126c8..9588716 100644 --- a/test/commands/notarize.test.js +++ b/test/commands/notarize.test.js @@ -4,7 +4,10 @@ const assert = require('assert') const {spawn} = require('child_process') +const crypto = require('crypto') const fs = require('fs') +const os = require('os') +const path = require('path') const setup = require('../test_setup') @@ -289,4 +292,651 @@ describe('notarize #12 - Alice\'s encrypted wallet', () => { it('exit code should be 0 if notarization succeeded with message data added from encrypted wallet', () => { assert.strictEqual(exitCode, 0) }) - }) \ No newline at end of file + }) +// /////////////////////////////////////////////////////////////////////////// +// Offline cases. +// +// notarize builds and pushes a message transaction, so every case below has to +// stop before that: either at a validation gate, or at a connection to a closed +// local port. Nothing is signed, nothing is broadcast and no OTS key is used. +// The wallet is created here rather than taken from the shared fixtures, so the +// block runs on its own. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback: the CLI resolves it, fails to connect, and exits. +const DEAD_NODE = '127.0.0.1:1' +const shortMnemonic = 'aback filled atop regal town opaque gloss send cheek ten fisher cow' + +let offlineDir +let offlineWallet +let offlineMnemonic +// A wallet in the legacy (pre-v2) encryption format holding an address that is not a QRL address. +// That format is unauthenticated, so a wrong password does not fail to decrypt, it yields +// something that is not an address — the check on the decrypted address is what has to catch it. +// Stored decrypted-to-nonsense rather than encrypted under another password so it is deterministic. +let legacyWallet + +// Encrypt the way the retired `aes256` package did: key = sha256(password), AES-256-CTR, +// base64(iv || ciphertext). +function legacyEncrypt(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(password).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + return Buffer.concat([iv, cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]).toString('base64') +} + +// Run the CLI and capture what it said, rather than letting it write to the test output. +function runNotarize(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +// Assert on the reason a case failed, not just on the exit code, so a command that starts +// failing somewhere else does not keep the test green. +async function notarizeRefuses(args, expected) { + const {code, out} = await runNotarize(args) + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}`) + expected.forEach(pattern => { + assert.ok(pattern.test(out), `expected output to match ${pattern}\n--- actual ---\n${out}`) + }) + return out +} + +describe('notarize offline', () => { + before(done => { + offlineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-notarize-')) + offlineWallet = path.join(offlineDir, 'wallet.json') + legacyWallet = path.join(offlineDir, 'legacy-wallet.json') + const child = spawn('./bin/run', ['create-wallet', '-3', '-h', '6', '-f', offlineWallet], processFlags) + child.on('exit', code => { + if (code !== 0) { + done(new Error(`create-wallet exited with code ${code}`)) + return + } + const created = openFile(offlineWallet)[0] + offlineMnemonic = created.mnemonic + fs.writeFileSync( + legacyWallet, + JSON.stringify([ + { + encrypted: true, + address: legacyEncrypt('the-right-password', 'this-is-not-a-qrl-address'), + addressB32: '', + pk: '', + hexseed: legacyEncrypt('the-right-password', created.hexseed), + mnemonic: legacyEncrypt('the-right-password', created.mnemonic), + height: created.height, + hashFunction: created.hashFunction, + signatureType: created.signatureType, + index: 0, + }, + ]) + ) + done() + }) + }) + + after(() => { + fs.rmSync(offlineDir, {recursive: true, force: true}) + }) + + it('requires a wallet or a hexseed', async () => { + await notarizeRefuses(['notarize', sha256Hash], [/No wallet.json file \(-w\) or hexseed/]) + }) + + it('requires an OTS index alongside a wallet file', async () => { + await notarizeRefuses(['notarize', sha256Hash, '-w', offlineWallet], [/No OTS index/]) + }) + + it('rejects a mnemonic with the wrong number of words', async () => { + await notarizeRefuses( + ['notarize', sha256Hash, '-h', shortMnemonic, '-i', '1'], + [/Mnemonic phrase invalid/] + ) + }) + + it('rejects a fee that is not a number', async () => { + await notarizeRefuses( + ['notarize', sha256Hash, '-h', offlineMnemonic, '-i', '1', '-f', 'none'], + [/Fee is invalid/] + ) + }) + + it('refuses a wallet whose decrypted address is not a QRL address', async () => { + await notarizeRefuses( + ['notarize', sha256Hash, '-w', legacyWallet, '-p', 'the-right-password', '-i', '1', '-g', DEAD_NODE], + [/Invalid password/] + ) + }) + + it('opens a wallet from a mnemonic and a fee, then stops at the unreachable node', async () => { + const out = await notarizeRefuses( + ['notarize', sha256Hash, '-h', offlineMnemonic, '-i', '1', '-f', '100', '-g', DEAD_NODE], + [/xmssPK returned/, /Failed to connect to node/] + ) + // Nothing may be signed or broadcast once the node is unreachable. + assert.ok(!/Transaction signed/.test(out), `nothing may be signed\n--- actual ---\n${out}`) + assert.ok(!/transaction ID/.test(out), `nothing may be broadcast\n--- actual ---\n${out}`) + assert.ok( + !/automated\.theqrl\.org/.test(out), + `a failed custom endpoint must not be replaced by a public one\n--- actual ---\n${out}` + ) + }) + + it('does not notarize anything when --json is passed', async () => { + // --json is currently broken end to end: it suppresses the spinner by setting it to null and + // then calls into it anyway, so the command dies before it reaches a node. Asserted as + // "produces no transaction", which stays true once that is fixed. + const {code, out} = await runNotarize(['notarize', sha256Hash, '-j']) + assert.notStrictEqual(code, 0) + assert.ok(!/tx_id/.test(out), `no transaction may be reported\n--- actual ---\n${out}`) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// notarize: what happens once a node has answered +// +// Everything above stops at a validation gate or at a closed loopback port, so +// the half of the command that runs after the node replies — the message +// transaction it builds, the binding check that decides whether to sign, the +// push, and the transaction-id check on the way back — was unreachable. +// +// These cases run the command in *this* process against a stub gRPC client. +// src/functions/grpc is swapped in the require cache for the moment it takes to +// require the command (it captures Qrlnode at require time), then the real +// module is put straight back. There is no server and no socket. +// +// The signing is real, against a throwaway height-6 wallet, and the stub +// recomputes the transaction hash the way a node does. Nothing reaches a +// network, so no on-chain OTS key is spent. +// +// Note the command only reports success on mainnet or testnet: with a custom +// --grpc endpoint the network is neither, and the success arm prints nothing. +// The cases below use -m/-t for that reason. +// /////////////////////////////////////////////////////////////////////////// + +const { + concatenateTypedArrays, + toBigendianUint64BytesUnsigned, + toUint8Vector, + binaryToBytes, +} = require('../../src/functions/tx-binding') + +// kleur colours by environment variable rather than by isTTY, so captured output +// still carries escape sequences. Strip them before matching. +const NOTARIZE_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +const NOTARIZE_OTHER_ADDRESS = 'Q000200ecffb27f3d7b11ccd048eb559277d64bb52bfda998341e66a9f11b2d07f6b2ee4f62c408' + +// Behaviour the stub should show for the test currently running. Reset per test. +let notarizeNode = {} +let notarizeCalls = [] +let notarizeConnectAttempts = 0 + +// Rebuild the transaction hash from the signed transaction the command pushed, +// the way QRL core does for a MessageTransaction: +// preimage = master_addr || fee || message_hash || addr_to +// hash = sha256(sha256(preimage) || signature || public key) +function notarizeTransactionHash(signedTx) { + const preimage = concatenateTypedArrays( + Uint8Array, + toBigendianUint64BytesUnsigned(parseInt(signedTx.fee, 10)), + Uint8Array.from(Buffer.from(signedTx.message.message_hash)), + Uint8Array.from(Buffer.from(signedTx.message.addr_to || [])) + ) + const digest = QRLLIB.sha2_256(toUint8Vector(preimage)) // eslint-disable-line no-undef + const whole = concatenateTypedArrays( + Uint8Array, + binaryToBytes(digest), + Uint8Array.from(signedTx.signature), + Uint8Array.from(signedTx.public_key) + ) + // eslint-disable-next-line no-undef + return Buffer.from(QRLLIB.bin2hstr(QRLLIB.sha2_256(toUint8Vector(whole))), 'hex') +} + +// What an honest node returns for GetMessageTxn. A notarisation has no +// recipient, so addr_to comes back empty. +const buildNotarizeResponse = request => ({ + extended_transaction_unsigned: { + tx: { + master_addr: Buffer.from(request.master_addr), + fee: String(request.fee), + message: { + message_hash: Buffer.from(request.message), + addr_to: Buffer.alloc(0), + }, + }, + }, +}) + +class NotarizeFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + notarizeConnectAttempts += 1 + if (notarizeNode.connectThrows) { + throw new Error(notarizeNode.connectThrows) + } + const connectsOn = notarizeNode.connectsOnAttempt === undefined ? 1 : notarizeNode.connectsOnAttempt + if (connectsOn !== 0 && notarizeConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + notarizeCalls.push({name, request}) + if (name === 'GetMessageTxn') { + const built = buildNotarizeResponse(request) + return notarizeNode.tamper ? notarizeNode.tamper(built) : built + } + if (notarizeNode.pushResponse) { + return notarizeNode.pushResponse + } + const hash = notarizeTransactionHash(request.transaction_signed) + return {tx_hash: notarizeNode.wrongHash ? Buffer.alloc(32, 0x11) : hash} + } +} + +const notarizeGrpcPath = require.resolve('../../src/functions/grpc') +const notarizeCommandPath = require.resolve('../../src/commands/notarize') + +const notarizeRealGrpcEntry = require.cache[notarizeGrpcPath] +require.cache[notarizeGrpcPath] = { + id: notarizeGrpcPath, + filename: notarizeGrpcPath, + path: path.dirname(notarizeGrpcPath), + loaded: true, + children: [], + paths: [], + exports: NotarizeFakeQrlNode, +} +const {Notarise} = require('../../src/commands/notarize') + +if (notarizeRealGrpcEntry) { + require.cache[notarizeGrpcPath] = notarizeRealGrpcEntry +} else { + delete require.cache[notarizeGrpcPath] +} + +// Run notarize here, against the stub, capturing everything it prints (this.log +// and console.log go to stdout, the ora spinners go to stderr). run() awaits the whole +// signing and pushing sequence, so an exit inside it arrives as a thrown ExitError. +async function runNotarizeInProcess(argv) { + const chunks = [] + const outChunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + // `stdout` is kept apart from the combined capture as well: --json promises that a + // caller reading stdout gets JSON or nothing, and that is only checkable per stream. + process.stdout.write = chunk => { + chunks.push(chunk.toString()) + outChunks.push(chunk.toString()) + return true + } + process.stderr.write = chunk => { + chunks.push(chunk.toString()) + return true + } + let code = 0 + try { + await Notarise.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + // kleur colours by environment variable rather than by isTTY, so the captured output + // still carries escape sequences here. Strip them so the assertions read as the text a + // person would see. + return { + code, + out: chunks.join('').replace(NOTARIZE_ANSI, ''), + stdout: outChunks.join('').replace(NOTARIZE_ANSI, ''), + } +} + +// cli-ux exposes `prompt` as a getter, so the wallet-password prompt has to be +// redefined rather than assigned. +const notarizeCliUx = require('cli-ux').cli // eslint-disable-line import/order + +function stubNotarizePassword(password) { + const saved = Object.getOwnPropertyDescriptor(notarizeCliUx, 'prompt') + const asked = [] + Object.defineProperty(notarizeCliUx, 'prompt', { + configurable: true, + get: () => async (message, options) => { + asked.push({message, options}) + return password + }, + }) + return {asked, restore: () => Object.defineProperty(notarizeCliUx, 'prompt', saved)} +} + +async function runNotarizeWithPassword(argv, password) { + const stub = stubNotarizePassword(password) + try { + const result = await runNotarizeInProcess(argv) + return {...result, asked: stub.asked} + } finally { + stub.restore() + } +} + +describe('notarize: signing and pushing what a node returned', () => { + let nodeDir + let nodeWallet + + before(function createNodeWallet(done) { + this.timeout(120000) + nodeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-notarize-node-')) + nodeWallet = path.join(nodeDir, 'wallet.json') + const child = spawn('./bin/run', ['create-wallet', '-3', '-h', '6', '-f', nodeWallet], processFlags) + child.on('exit', code => (code === 0 ? done() : done(new Error(`create-wallet exited ${code}`)))) + }) + + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[notarizeCommandPath] + fs.rmSync(nodeDir, {recursive: true, force: true}) + }) + + beforeEach(() => { + notarizeNode = {} + notarizeCalls = [] + notarizeConnectAttempts = 0 + }) + + const notarizeArgs = (extra = []) => [sha256Hash, '-i', '0', '-w', nodeWallet, '-t', ...extra] + + it('notarises the hash it was given, prefixed with the notarisation marker', async function builds() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 0, out) + const build = notarizeCalls.find(c => c.name === 'GetMessageTxn') + assert.ok(build, `no GetMessageTxn call was made\n--- output ---\n${out}`) + // 'AFAFA' + '2' + the sha256 hash, as hex bytes + assert.strictEqual(Buffer.from(build.request.message).toString('hex'), `afafa2${sha256Hash}`) + assert.strictEqual(build.request.fee, 0) + }) + + it('appends user message data to the notarisation', async function withMessage() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess( + notarizeArgs(['-M', messageData]) + ) + assert.strictEqual(code, 0, out) + const build = notarizeCalls.find(c => c.name === 'GetMessageTxn') + const sent = Buffer.from(build.request.message).toString('hex') + assert.ok(sent.startsWith(`afafa2${sha256Hash}`), sent) + assert.ok(sent.includes(Buffer.from(messageData).toString('hex')), sent) + }) + + it('reports a hexseed the XMSS library cannot use, rather than failing silently', async function badSeed() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess([sha256Hash, '-i', '0', '-h', '020200cb68ca52ae4aff1d2ac10a2cc03f2325b95ab4610d2c6fd2af684aa1427766ac0b96b05942734d254fb9dba5fcb139HG', '-t']) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to recreate XMSS wallet object/.test(out), out) + assert.strictEqual(notarizeCalls.length, 0, 'no node is contacted when the key cannot be rebuilt') + }) + + it('accepts an explicit fee of 0, the same value it uses when -f is omitted', async function zeroFee() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess(notarizeArgs(['-f', '0'])) + assert.strictEqual(code, 0, out) + assert.strictEqual(notarizeCalls.find(c => c.name === 'GetMessageTxn').request.fee, 0) + }) + + it('signs, pushes, and reports the id the node gave back', async function signs() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess( + notarizeArgs(['-f', '100']) + ) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + const push = notarizeCalls.find(c => c.name === 'PushTransaction') + assert.ok(push.request.transaction_signed.signature.length > 0, 'pushed without a signature') + assert.strictEqual(push.request.transaction_signed.fee, '100') + const hash = notarizeTransactionHash(push.request.transaction_signed).toString('hex') + assert.ok(out.includes(`transaction ID: ${hash}`), out) + assert.ok(out.includes(`https://testnet-explorer.theqrl.org/tx/${hash}`), out) + }) + + it('reports the transaction id on a custom endpoint too', async function customEndpoint() { + this.timeout(120000) + // There is no explorer to link to for a custom node, but a successful + // notarisation still has to name the transaction it made: this used to + // print nothing at all unless the network was mainnet or testnet. + const {code, out} = await runNotarizeInProcess([sha256Hash, '-i', '0', '-w', nodeWallet, '-g', DEAD_NODE]) + assert.strictEqual(code, 0, out) + const push = notarizeCalls.find(c => c.name === 'PushTransaction') + const hash = notarizeTransactionHash(push.request.transaction_signed).toString('hex') + assert.ok(out.includes(`transaction ID: ${hash}`), out) + assert.ok(!/explorer\.theqrl\.org/.test(out), 'no explorer link for a network with no explorer') + }) + + it('links to the mainnet explorer when notarising on mainnet', async function mainnet() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess( + [sha256Hash, '-i', '0', '-w', nodeWallet, '-m'] + ) + assert.strictEqual(code, 0, out) + assert.ok(/https:\/\/explorer\.theqrl\.org\/tx\/[0-9a-f]{64}/.test(out), out) + }) + + it('retries the connection until the node answers', async function retries() { + this.timeout(120000) + notarizeNode = {connectsOnAttempt: 3} + const {code} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 0) + assert.strictEqual(notarizeConnectAttempts, 3) + }) + + it('refuses to sign a response that rewrote the notarisation data', async function tamperedData() { + this.timeout(120000) + // The notarisation data is the whole point of the transaction: a node that + // rewrites it and gets a signature has notarised something else entirely. + notarizeNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.message.message_hash = Buffer.from('afafa2', 'hex') + return response + }, + } + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different notarisation data/.test(out), out) + assert.ok(/Nothing was signed and no OTS key was used/.test(out), out) + assert.strictEqual(notarizeCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('refuses to sign a response that added a recipient', async function tamperedRecipient() { + this.timeout(120000) + // A notarisation has no recipient. One appearing in the response means the + // node turned it into an addressed message. + notarizeNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.message.addr_to = Buffer.from(NOTARIZE_OTHER_ADDRESS.substring(1), 'hex') + return response + }, + } + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different recipient/.test(out), out) + }) + + it('refuses to sign a response that inflated the fee', async function tamperedFee() { + this.timeout(120000) + notarizeNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.fee = '100000000' + return response + }, + } + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different fee/.test(out), out) + }) + + it('reports a node that rejects the push', async function pushRejected() { + this.timeout(120000) + notarizeNode = {pushResponse: {error_code: 'INVALID', error_description: 'OTS key reused'}} + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/OTS key reused/.test(out), out) + }) + + it('refuses a transaction id that is not the one it signed', async function hashMismatch() { + this.timeout(120000) + notarizeNode = {wrongHash: true} + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/Node transaction hash 1111/.test(out), out) + }) + + it('reports a connection that fails outright', async function connectFailed() { + this.timeout(120000) + notarizeNode = {connectThrows: 'no route to host'} + const {code, out} = await runNotarizeInProcess(notarizeArgs()) + assert.strictEqual(code, 1, out) + assert.strictEqual(notarizeCalls.length, 0, 'nothing may be asked of a node that never connected') + }) + + describe('--json', () => { + it('prints the transaction id as JSON and nothing else on stdout', async function json() { + this.timeout(120000) + const {code, out} = await runNotarizeInProcess(notarizeArgs(['-j'])) + assert.strictEqual(code, 0, out) + const push = notarizeCalls.find(c => c.name === 'PushTransaction') + const hash = notarizeTransactionHash(push.request.transaction_signed).toString('hex') + assert.deepStrictEqual(JSON.parse(out), [{tx_id: hash}]) + }) + + it('reports why it failed instead of exiting silently', async function jsonFailure() { + this.timeout(120000) + // The progress spinners are stood down in JSON mode, but a failure still has + // to say something: a bare non-zero exit would be indistinguishable from a + // crash, and this command used to die outright the moment --json was given. + notarizeNode = {connectThrows: 'no route to host'} + const {code, out} = await runNotarizeInProcess(notarizeArgs(['-j'])) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('says nothing on stdout when it fails', async function jsonFailureStdout() { + this.timeout(120000) + // Everything the spinners print goes to stderr, so a caller reading stdout + // gets valid JSON or nothing at all - never half a progress report. + notarizeNode = {connectThrows: 'no route to host'} + const {code, stdout} = await runNotarizeInProcess(notarizeArgs(['-j'])) + assert.strictEqual(code, 1) + assert.strictEqual(stdout.trim(), '') + }) + + it('still reports a missing OTS index', async function jsonNoOts() { + this.timeout(120000) + // This gate clears the spinner line with an empty second message, which the + // stand-in has to swallow rather than print as a blank line of noise. + const {code, out, stdout} = await runNotarizeInProcess([sha256Hash, '-w', nodeWallet, '-j', '-t']) + assert.strictEqual(code, 1, out) + // the reason, and nothing else: the empty second message is swallowed rather + // than printed as a blank line + assert.deepStrictEqual( + out.split('\n').filter((line) => line !== ''), + ['No OTS index (-i) given...'] + ) + assert.strictEqual(stdout, '', 'stdout stays JSON-only, so it says nothing at all here') + assert.strictEqual(notarizeCalls.length, 0, 'it never reaches a node') + }) + + it('drops the progress chatter that a plain run prints', async function jsonQuiet() { + this.timeout(120000) + const {out} = await runNotarizeInProcess(notarizeArgs(['-j'])) + assert.ok(!/notarization:/.test(out), out) + assert.ok(!/xmssPK returned/.test(out), out) + assert.ok(!/Transaction submitted to/.test(out), out) + }) + }) + describe('wallet password', () => { + const encWallet = '/tmp/notarize-node-enc-wallet.json' + const WALLET_PASSWORD = 'prompted-password' + let plainWallet + + before(() => { + const aes = require('../../src/utils/aes') // eslint-disable-line global-require + ;[plainWallet] = openFile(nodeWallet) + fs.writeFileSync( + encWallet, + JSON.stringify([ + { + encrypted: true, + address: aes.encrypt(WALLET_PASSWORD, plainWallet.address), + hexseed: aes.encrypt(WALLET_PASSWORD, plainWallet.hexseed), + }, + ]) + ) + }) + + after(() => { + try { + fs.unlinkSync(encWallet) + } catch (error) { + // never created; nothing to clean up + } + }) + + it('asks for the password when --password is not given', async function passwordPrompt() { + this.timeout(120000) + // Without this branch an encrypted wallet would only be usable with the + // password on the command line, where it lands in shell history. + const {code, out, asked} = await runNotarizeWithPassword( + [sha256Hash, '-i', '0', '-w', encWallet, '-t'], + WALLET_PASSWORD + ) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.length, 1) + assert.ok(/Enter password for wallet file/.test(asked[0].message)) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + }) + + it('refuses a wrong password typed at the prompt', async function wrongPassword() { + this.timeout(120000) + const {code, out} = await runNotarizeWithPassword( + [sha256Hash, '-i', '0', '-w', encWallet, '-t'], + 'not-the-password' + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(notarizeCalls.length, 0, 'nothing is signed or sent for a wallet that never opened') + }) + }) + + it('reports a signing failure that is not a binding failure', async function badOts() { + this.timeout(120000) + // OTS index 999 does not exist in a height-6 tree, so the response binds + // cleanly and it is xmss.sign() that fails. + const {code, out} = await runNotarizeInProcess( + [sha256Hash, '-i', '999', '-w', nodeWallet, '-t'] + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(notarizeCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) +}) diff --git a/test/commands/ots.test.js b/test/commands/ots.test.js index 5b804db..8d554ba 100644 --- a/test/commands/ots.test.js +++ b/test/commands/ots.test.js @@ -1,10 +1,48 @@ +// /////////////////////////////////////////////////////////////////////////// +// ots command tests +// +// `ots` reports the next unused one-time-signature index for an address, so it +// needs a node before it can answer -- but everything in front of that answer +// is local: address validation, the wallet-file fallback, and the password +// handling that decrypts an encrypted wallet. That front half is what these +// tests cover, and it is the half that decides *which* address the query is +// made for. +// +// The cases are split: +// * the original cases that query a real public node are skipped when +// QRL_TEST_OFFLINE=true, the same switch test/hooks.js already uses. +// * everything added below either stops at a local validation gate or +// connects to a closed loopback port, so nothing leaves the machine and no +// OTS key is ever consumed. +// +// Child processes get a throwaway config directory, because `ots` resolves its +// endpoint through the `conf` store. +// /////////////////////////////////////////////////////////////////////////// + const assert = require('assert') -const { spawn } = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') +const {spawn} = require('child_process') const processFlags = { detached: true, stdio: ['ignore', 'inherit', 'inherit'], } + +// The cases that need a live node. Skipped rather than deleted so they still +// run in the normal, networked CI job. +const describeOnline = process.env.QRL_TEST_OFFLINE === 'true' ? describe.skip : describe + +// A closed port on loopback: refused immediately, deterministically, locally. +const DEAD_NODE = '127.0.0.1:1' + +const VALID_ADDRESS = 'Q000500b5ea246980f3ff4ee42f399e4a79598d6844e66373eb61ab59d1a1e6cfe8e963eb4bcd7f' +const WALLET_PASSWORD = 'testing' + +// kleur colours its output even into a pipe; strip the escapes before matching. +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + // no args describe('ots #1', () => { let exitCode @@ -43,19 +81,33 @@ describe('ots #2', () => { }) // bad address file given +// A directory: existsSync says yes, so the command tries to read it as a wallet. It used +// to die on the raw EISDIR that throws out of that read, which is a non-zero exit for a +// reason this case never meant to test - so the reason is asserted now, not just the code. describe('ots #3', () => { let exitCode + let out = '' before(done => { const args = [ 'ots', '/tmp', ] - const process = spawn('./bin/run', args, processFlags) - process.on('exit', code => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('exit', code => { exitCode = code done() }) }) + it('says the path is not a usable wallet file rather than crashing', () => { + assert.ok(/Unable to get OTS: not a file/.test(out), out) + assert.ok(!/EISDIR/.test(out), `the read error must not escape\n--- actual ---\n${out}`) + }) it('exit code should be non-0 if passed with an invalid address file as argument', () => { assert.notStrictEqual(exitCode, 0) }) @@ -107,7 +159,7 @@ describe('ots #5', () => { // valid args should succeed -describe('ots #6', () => { +describeOnline('ots #6', () => { let exitCode before(done => { const args = [ @@ -126,7 +178,7 @@ describe('ots #6', () => { }) // valid mainnet flag -describe('ots #7', () => { +describeOnline('ots #7', () => { let exitCode before(done => { const args = [ @@ -146,7 +198,7 @@ describe('ots #7', () => { }) // valid testnet flag -describe('ots #8', () => { +describeOnline('ots #8', () => { let exitCode before(done => { const args = [ @@ -163,4 +215,596 @@ describe('ots #8', () => { it('exit code should be 0 if passed with testnet flag and a valid address as argument', () => { assert.strictEqual(exitCode, 0) }) -}) \ No newline at end of file +}) + +// /////////////////////////////////////////////////////////////////////////// +// Offline coverage of everything that happens before the node is queried. +// +// The cases above assert only exit codes, so an `ots` that refused for the +// wrong reason -- the wrong file read, the wrong address decrypted -- looked +// exactly like one that refused for the right one. These assert on the message, +// and on which address the command decided to ask about. +// /////////////////////////////////////////////////////////////////////////// + +describe('ots: local validation and the wallet-file fallback', () => { + let tempDir + let childEnv + let plainWallet + let encryptedWallet + let encryptedGarbageWallet + let noEncryptedFlagWallet + let emptyArrayWallet + let notJsonFile + + const write = (name, contents) => { + const file = path.join(tempDir, name) + fs.writeFileSync(file, contents) + return file + } + + function run(args, stdin) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, { + stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + env: childEnv, + }) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out: out.replace(ANSI, '')})) + if (stdin !== undefined) { + child.stdin.end(stdin) + } + }) + } + + before(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-ots-test-')) + childEnv = { + ...process.env, + HOME: tempDir, + XDG_CONFIG_HOME: tempDir, + APPDATA: tempDir, + LOCALAPPDATA: tempDir, + } + // eslint-disable-next-line global-require + const aes = require('../../src/utils/aes') + + plainWallet = write('plain-wallet.json', JSON.stringify([{encrypted: false, address: VALID_ADDRESS}])) + encryptedWallet = write( + 'enc-wallet.json', + JSON.stringify([{encrypted: true, address: aes.encrypt(WALLET_PASSWORD, VALID_ADDRESS)}]) + ) + // Decrypts cleanly with the right password, but to something that is not an + // address. This is the shape a legacy (unauthenticated) wallet takes when + // opened with the wrong password, and the only thing that catches it is the + // address check after the decrypt. + encryptedGarbageWallet = write( + 'enc-garbage-wallet.json', + JSON.stringify([{encrypted: true, address: aes.encrypt(WALLET_PASSWORD, 'not-an-address')}]) + ) + noEncryptedFlagWallet = write('no-flag-wallet.json', JSON.stringify([{address: VALID_ADDRESS}])) + emptyArrayWallet = write('empty-array-wallet.json', '[]') + notJsonFile = write('not-json.txt', 'this is not a wallet') + }) + + after(() => { + fs.rmSync(tempDir, {recursive: true, force: true}) + }) + + it('reports a missing address when stdin is empty and there is no TTY', async () => { + const {code, out} = await run(['ots'], '') + assert.strictEqual(code, 1) + assert.ok(/Missing QRL address or wallet file/.test(out), out) + }) + + it('rejects a string that is neither a valid address nor an existing file', async () => { + const {code, out} = await run(['ots', 'Qdefinitelynotanaddress']) + assert.strictEqual(code, 1) + assert.ok(/Unable to get OTS: invalid QRL address\/wallet file/.test(out), out) + }) + + it('takes the address from an unencrypted wallet file', async () => { + // Getting this far means the file was parsed and the address inside it + // accepted; the only thing left is the node, which is a closed port. + const {code, out} = await run(['ots', plainWallet, '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node/.test(out), out) + assert.ok(!/invalid QRL address\/wallet file/.test(out), out) + }) + + it('decrypts an encrypted wallet file given the right password', async () => { + const {code, out} = await run([ + 'ots', + encryptedWallet, + '-p', + WALLET_PASSWORD, + '-g', + DEAD_NODE, + ]) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node/.test(out), out) + assert.ok(!/invalid password/.test(out), out) + }) + + it('refuses an encrypted wallet file given the wrong password', async () => { + // The current format is authenticated, so a wrong password makes the + // decrypt itself throw rather than yield garbage. + const {code, out} = await run(['ots', encryptedWallet, '-p', 'NotThePassword']) + assert.strictEqual(code, 1) + assert.ok(!/Failed to connect to node/.test(out), out) + }) + + it('refuses a wallet whose decrypted contents are not an address', async () => { + // The decrypt succeeds here; only the address check afterwards catches it. + // Without that check the command would go on to query the node for a + // Buffer built from arbitrary bytes. + const {code, out} = await run([ + 'ots', + encryptedGarbageWallet, + '-p', + WALLET_PASSWORD, + '-g', + DEAD_NODE, + ]) + assert.strictEqual(code, 1) + assert.ok(/Unable to open wallet file: invalid password/.test(out), out) + assert.ok(!/Failed to connect to node/.test(out), out) + }) + + it('refuses a wallet file with no `encrypted` flag rather than guessing', async () => { + const {code, out} = await run(['ots', noEncryptedFlagWallet, '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(/Unable to get a OTS: invalid QRL address\/wallet file/.test(out), out) + }) + + it('refuses an empty wallet array', async () => { + const {code, out} = await run(['ots', emptyArrayWallet, '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(!/Failed to connect to node/.test(out), out) + }) + + it('refuses a file that is not JSON at all', async () => { + const {code, out} = await run(['ots', notJsonFile, '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(!/Failed to connect to node/.test(out), out) + }) + + it('reads the address from stdin when none is given', async () => { + const {code, out} = await run(['ots', '-g', DEAD_NODE], `${VALID_ADDRESS}\n`) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node/.test(out), out) + }) + + it('reads the address from stdin when the argument is "-"', async () => { + const {code, out} = await run(['ots', '-', '-g', DEAD_NODE], `${VALID_ADDRESS}\n`) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node/.test(out), out) + }) +}) + +describe('ots: when the node cannot be reached', () => { + let tempDir + let childEnv + + function run(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe'], env: childEnv}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out: out.replace(ANSI, '')})) + }) + } + + before(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-ots-net-')) + childEnv = { + ...process.env, + HOME: tempDir, + XDG_CONFIG_HOME: tempDir, + APPDATA: tempDir, + LOCALAPPDATA: tempDir, + } + }) + + after(() => { + fs.rmSync(tempDir, {recursive: true, force: true}) + }) + + it('names the endpoint before querying it', async () => { + const {code, out} = await run(['ots', VALID_ADDRESS, '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(out.includes(`Custom GRPC endpoint: [${DEAD_NODE}]`), out) + assert.ok(/Failed to connect to node\. Check network connection & parameters/.test(out), out) + }) + + it('reports the failure without a spinner in --json mode', async () => { + // --json suppresses the spinner, so the failure takes the other branch. A + // caller parsing this output must not be left with a silent non-zero exit. + const {code, out} = await run(['ots', VALID_ADDRESS, '--json', '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node:/.test(out), out) + assert.ok(!out.includes('Custom GRPC endpoint:'), out) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// The interactive paths. +// +// The address prompt only runs when both streams are TTYs, and the wallet +// password prompt reads from the terminal, neither of which a spawned child +// with pipes can be. These drive the command in-process with those inputs +// stubbed. +// +// Every case here is answered in a way that stops the command before it would +// query a node, so no connection of any kind is opened. +// /////////////////////////////////////////////////////////////////////////// + +describe('ots: the interactive prompts', () => { + let savedEnv + let tempDir + let oclifConfig + let OTSKey + let cliUx + let garbageWallet + let encryptedWallet + + const CONFIG_ENV = ['HOME', 'XDG_CONFIG_HOME', 'APPDATA', 'LOCALAPPDATA'] + + before(async () => { + // `conf` is instantiated when the command's modules load, so the config + // directory has to be redirected before they are required. + savedEnv = {} + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-ots-prompt-')) + CONFIG_ENV.forEach(k => { + savedEnv[k] = process.env[k] + process.env[k] = tempDir + }) + + // oclif's help layer measures the terminal at require time, so it has to be + // loaded before the TTY flags are forced on. + // eslint-disable-next-line global-require + oclifConfig = await require('@oclif/config').load(path.join(__dirname, '..', '..')) + // eslint-disable-next-line global-require + cliUx = require('cli-ux').cli + // eslint-disable-next-line global-require + const aes = require('../../src/utils/aes') + // eslint-disable-next-line global-require + OTSKey = require('../../src/commands/ots').OTSKey + + encryptedWallet = path.join(tempDir, 'enc-wallet.json') + fs.writeFileSync( + encryptedWallet, + JSON.stringify([{encrypted: true, address: aes.encrypt(WALLET_PASSWORD, VALID_ADDRESS)}]) + ) + // Decrypts cleanly with the right password, but not to an address. + garbageWallet = path.join(tempDir, 'garbage-wallet.json') + fs.writeFileSync( + garbageWallet, + JSON.stringify([{encrypted: true, address: aes.encrypt(WALLET_PASSWORD, 'not-an-address')}]) + ) + }) + + after(() => { + CONFIG_ENV.forEach(k => { + if (savedEnv[k] === undefined) { + delete process.env[k] + } else { + process.env[k] = savedEnv[k] + } + }) + fs.rmSync(tempDir, {recursive: true, force: true}) + }) + + // Replace the lazily-required `prompts` module for the duration of one run. + function stubPrompts(fake) { + const promptsPath = require.resolve('prompts') + const saved = require.cache[promptsPath] + // eslint-disable-next-line global-require + const Module = require('module') + const stub = new Module(promptsPath, null) + stub.filename = promptsPath + stub.loaded = true + stub.exports = fake + require.cache[promptsPath] = stub + return () => { + if (saved === undefined) { + delete require.cache[promptsPath] + } else { + require.cache[promptsPath] = saved + } + } + } + + // cli-ux exposes `prompt` as a getter, so it has to be redefined rather than + // assigned. + function stubPassword(password) { + const saved = Object.getOwnPropertyDescriptor(cliUx, 'prompt') + Object.defineProperty(cliUx, 'prompt', { + configurable: true, + get: () => async () => password, + }) + return () => Object.defineProperty(cliUx, 'prompt', saved) + } + + async function runInProcess(argv, {fakePrompts, fakePassword} = {}) { + const restorePrompts = stubPrompts(fakePrompts || (async () => ({}))) + const restorePassword = fakePassword === undefined ? () => {} : stubPassword(fakePassword) + const savedStdout = process.stdout.isTTY + const savedStdin = process.stdin.isTTY + const savedWrite = process.stdout.write + const savedErrWrite = process.stderr.write + const savedWindowSize = process.stdout.getWindowSize + let out = '' + const capture = chunk => { + out += chunk.toString() + return true + } + process.stdout.isTTY = true + process.stdin.isTTY = true + if (!process.stdout.getWindowSize) { + process.stdout.getWindowSize = () => [80, 24] + } + process.stdout.write = capture + process.stderr.write = capture + try { + const cmd = new OTSKey(argv, oclifConfig) + await cmd.run() + return {code: 0, out: out.replace(ANSI, '')} + } catch (error) { + const code = error.oclif ? error.oclif.exit : 1 + return {code, out: out.replace(ANSI, '')} + } finally { + process.stdout.write = savedWrite + process.stderr.write = savedErrWrite + process.stdout.getWindowSize = savedWindowSize + process.stdout.isTTY = savedStdout + process.stdin.isTTY = savedStdin + restorePassword() + restorePrompts() + } + } + + it('uses the answer typed at the address prompt', async () => { + // Answered with something that is neither an address nor a file, so the + // command stops at the local gate: what is being checked is that the typed + // answer is the value it went on to use. + let asked + const {code, out} = await runInProcess([], { + fakePrompts: async options => { + asked = options + return {address: 'Qtyped-at-the-prompt'} + }, + }) + assert.strictEqual(code, 1) + assert.ok(/Unable to get OTS: invalid QRL address\/wallet file/.test(out), out) + assert.strictEqual(asked.name, 'address') + assert.ok(/QRL address or path to wallet\.json/.test(asked.message)) + }) + + it('will not accept an empty answer at the address prompt', async () => { + let asked + await runInProcess([], { + fakePrompts: async options => { + asked = options + return {address: 'Qtyped-at-the-prompt'} + }, + }) + assert.strictEqual(asked.validate(''), 'Address/File is required') + assert.strictEqual(asked.validate(VALID_ADDRESS), true) + }) + + it('exits non-zero when the address prompt is cancelled', async () => { + const {code, out} = await runInProcess([], {fakePrompts: async () => ({})}) + assert.strictEqual(code, 1) + assert.ok(/Operation cancelled/.test(out), out) + }) + + it('asks for the wallet password when --password is not given', async () => { + // Without this branch an encrypted wallet would be unusable unless the + // password were put on the command line, where it lands in shell history. + // The wallet here decrypts to something that is not an address, so the + // command stops at the address check rather than reaching a node. + const {code, out} = await runInProcess([garbageWallet], {fakePassword: WALLET_PASSWORD}) + assert.strictEqual(code, 1) + assert.ok(/Unable to open wallet file: invalid password/.test(out), out) + }) + + it('says the password was the problem, rather than exiting silently', async () => { + // The v2 wallet format is authenticated, so a wrong password makes decryption throw. + // That was swallowed: exit 1 with nothing printed and no hint at the cause. + const {code, out} = await runInProcess([encryptedWallet, '-p', 'not-the-password']) + assert.strictEqual(code, 1, out) + assert.ok(/Error decrypting wallet/.test(out), out) + }) + + it('refuses a wrong password typed at the wallet prompt', async () => { + const {code, out} = await runInProcess([encryptedWallet], {fakePassword: 'NotThePassword'}) + assert.strictEqual(code, 1) + assert.ok(!/Fetching OTS from API/.test(out), out) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// ots: what the command does once a node has answered +// +// Everything above stops at the connection or at a local validation gate. This +// suite runs the command in *this* process against a stub gRPC client so the +// retry loop and all three endings of a GetOTS reply - the key in plain text, +// the key as JSON, and "no key found" - are covered without a node, a socket, +// or a packet leaving the machine. `ots` only reads; nothing is ever signed. +// /////////////////////////////////////////////////////////////////////////// + +// Behaviour the stub should show for the test currently running. Reset per test. +let otsNode = {} +let otsRequests = [] +let otsConnectAttempts = 0 + +class OtsFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + otsConnectAttempts += 1 + if (otsNode.connectThrows) { + throw new Error(otsNode.connectThrows) + } + const connectsOn = otsNode.connectsOnAttempt === undefined ? 1 : otsNode.connectsOnAttempt + if (connectsOn !== 0 && otsConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + otsRequests.push({name, request}) + if (otsNode.apiThrows) { + throw new Error(otsNode.apiThrows) + } + return otsNode.ots + } +} + +describe('ots: reporting a node reply', () => { + const otsGrpcPath = require.resolve('../../src/functions/grpc') + const otsCommandPath = require.resolve('../../src/commands/ots') + let StubbedOTSKey + let savedCommandEntry + + before(() => { + // The command captures Qrlnode at require time, so it has to be required + // afresh with the stub in place. The suite above already cached the real + // one; both entries are put back in `after`. + savedCommandEntry = require.cache[otsCommandPath] + delete require.cache[otsCommandPath] + + const realGrpcEntry = require.cache[otsGrpcPath] + require.cache[otsGrpcPath] = { + id: otsGrpcPath, + filename: otsGrpcPath, + path: path.dirname(otsGrpcPath), + loaded: true, + children: [], + paths: [], + exports: OtsFakeQrlNode, + } + // eslint-disable-next-line global-require + StubbedOTSKey = require('../../src/commands/ots').OTSKey + if (realGrpcEntry) { + require.cache[otsGrpcPath] = realGrpcEntry + } else { + delete require.cache[otsGrpcPath] + } + }) + + after(() => { + if (savedCommandEntry === undefined) { + delete require.cache[otsCommandPath] + } else { + require.cache[otsCommandPath] = savedCommandEntry + } + }) + + beforeEach(() => { + otsNode = {ots: {unused_ots_index_found: true, next_unused_ots_index: 12}} + otsRequests = [] + otsConnectAttempts = 0 + }) + + async function runOtsOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = chunk => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await StubbedOTSKey.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + return {code, out: chunks.join('').replace(ANSI, '')} + } + + it('asks the node for the address it was given, as bytes', async () => { + const {code} = await runOtsOffline(['-g', DEAD_NODE, VALID_ADDRESS]) + assert.strictEqual(code, 0) + assert.strictEqual(otsRequests.length, 1) + assert.strictEqual(otsRequests[0].name, 'GetOTS') + assert.strictEqual(otsRequests[0].request.address.toString('hex'), VALID_ADDRESS.substring(1)) + }) + + it('retries the connection until the node answers', async () => { + otsNode = {ots: {unused_ots_index_found: true, next_unused_ots_index: 0}, connectsOnAttempt: 3} + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(otsConnectAttempts, 3) + assert.ok(/retry connection attempt: 0/.test(out), out) + }) + + it('retries quietly in JSON mode, where there is no spinner to update', async () => { + otsNode = {ots: {unused_ots_index_found: true, next_unused_ots_index: 4}, connectsOnAttempt: 3} + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, '-j', VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.strictEqual(otsConnectAttempts, 3) + assert.ok(!/retry connection attempt/.test(out), out) + assert.deepStrictEqual(JSON.parse(out), [{next_key: 4}]) + }) + + it('reports the next unused key', async () => { + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.ok(/Next unused OTS key: 12/.test(out), out) + }) + + it('reports the next unused key as JSON, with nothing else on stdout', async () => { + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, '-j', VALID_ADDRESS]) + assert.strictEqual(code, 0, out) + assert.deepStrictEqual(JSON.parse(out), [{next_key: 12}]) + }) + + it('exits non-zero when the node reports no unused key', async () => { + // A wallet with every OTS key spent: signing anything further would reuse a + // key, so this has to fail rather than report a default of 0. + otsNode = {ots: {unused_ots_index_found: false}} + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, VALID_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to fetch an OTS key/.test(out), out) + }) + + it('says so in JSON mode too when there is no unused key', async () => { + // There is no spinner to fail in JSON mode, so the message takes the other + // branch: a silent non-zero exit would be indistinguishable from a crash. + otsNode = {ots: {unused_ots_index_found: false}} + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, '-j', VALID_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to fetch an OTS key/.test(out), out) + }) + + it('reports a connect() failure as a plain log line in JSON mode', async () => { + otsNode = {connectThrows: 'no route to host'} + const {code, out} = await runOtsOffline(['-g', DEAD_NODE, '-j', VALID_ADDRESS]) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to connect to node: Error: no route to host/.test(out), out) + }) +}) diff --git a/test/commands/receive.test.js b/test/commands/receive.test.js index 3515115..0203ae8 100644 --- a/test/commands/receive.test.js +++ b/test/commands/receive.test.js @@ -1,7 +1,10 @@ const assert = require('assert') const {spawn} = require('child_process') +const fs = require('fs') +const path = require('path') const testSetup = require('../test_setup') +const aes = require('../../src/utils/aes') const processFlags = { detached: true, @@ -52,6 +55,7 @@ describe('receive #2', () => { // bad args given describe('receive #3', () => { let exitCode + let out = '' before(done => { const args = [ 'receive', @@ -59,8 +63,14 @@ describe('receive #3', () => { '-p', 'wrongPassword' ] - const process = spawn('./bin/run', args, processFlags) - process.on('exit', code => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('exit', code => { exitCode = code done() }) @@ -68,6 +78,9 @@ describe('receive #3', () => { it('exit code should be non-0 if passed with bad password to encrypted wallet', () => { assert.notStrictEqual(exitCode, 0) }) + it('says the password was the problem, rather than exiting silently', () => { + assert.ok(/Error decrypting wallet/.test(out), `expected a reason\n--- actual ---\n${out}`) + }) }) // bad address given @@ -128,3 +141,216 @@ describe('receive #6', () => { assert.notStrictEqual(exitCode, 0) }) }) + +// /////////////////////////////////////////////////////////////////////////// +// Wallet file handling +// +// Everything above stops at a gate; nothing here had ever walked `receive` all +// the way through a wallet file, so the only paths that were exercised were the +// ones that print "Invalid QRL address/wallet file" and quit. The cases below +// cover the rest: a plaintext wallet, an encrypted wallet opened with -p, an +// encrypted wallet opened from the interactive prompt, a file whose ciphertext +// decrypts to something that is not an address, and a file with no usable +// `encrypted` flag at all. +// +// Offline and self-contained: the wallets are built in the before() hook, and +// `receive` never talks to a node. +// /////////////////////////////////////////////////////////////////////////// + +const WALLET_PASSWORD = 'testpassword' + +const ptWallet = '/tmp/recv-wallet.json' +const encWallet = '/tmp/recv-wallet-enc.json' +// An encrypted wallet whose address decrypts cleanly but is not a QRL address: +// the only way to reach the "invalid password" branch, since a wrong password +// makes aes.decrypt throw instead. +const decryptsToJunkWallet = '/tmp/recv-wallet-junk-address.json' +// A wallet file that is valid JSON but claims neither encrypted:true nor false. +const noEncryptedFlagWallet = '/tmp/recv-wallet-no-flag.json' + +// Run the CLI and capture what it said, so the assertions can be about the +// message and not only about the exit code. +function runReceive(args, stdin) { + return new Promise(resolve => { + const child = spawn('./bin/run', ['receive', ...args], { + stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + if (stdin !== undefined) { + child.stdin.end(stdin) + } + child.on('close', code => resolve({code, out})) + }) +} + +function createWallet(args) { + return new Promise((resolve, reject) => { + const child = spawn('./bin/run', ['create-wallet', '-h', '4', ...args], processFlags) + child.on('exit', code => { + if (code === 0) { + resolve() + } else { + reject(new Error(`create-wallet exited ${code}`)) + } + }) + }) +} + +const openWallet = file => JSON.parse(fs.readFileSync(file))[0] + +describe('receive: wallet files', () => { + let ptAddress + let encAddress + + before(async function makeWallets() { + this.timeout(120000) + await createWallet(['-f', ptWallet]) + await createWallet(['-f', encWallet, '-p', WALLET_PASSWORD]) + + ptAddress = openWallet(ptWallet).address + encAddress = aes.decrypt(WALLET_PASSWORD, openWallet(encWallet).address) + + fs.writeFileSync( + decryptsToJunkWallet, + JSON.stringify([ + { + encrypted: true, + address: aes.encrypt(WALLET_PASSWORD, 'not-a-qrl-address'), + }, + ]) + ) + fs.writeFileSync( + noEncryptedFlagWallet, + JSON.stringify([{encrypted: 'maybe', address: ptAddress}]) + ) + }) + + after(() => { + [ptWallet, encWallet, decryptsToJunkWallet, noEncryptedFlagWallet].forEach(file => { + try { + fs.unlinkSync(file) + } catch (error) { + // the file was never created; nothing to clean up + } + }) + }) + + it('prints the address held in a plaintext wallet file', async () => { + const {code, out} = await runReceive([ptWallet]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes(ptAddress), `expected the wallet address in:\n${out}`) + }) + + it('prints the address held in an encrypted wallet file given -p', async () => { + const {code, out} = await runReceive([encWallet, '-p', WALLET_PASSWORD]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes(encAddress), `expected the decrypted address in:\n${out}`) + }) + + it('rejects a wallet whose ciphertext does not decrypt to a QRL address', async () => { + const {code, out} = await runReceive([decryptsToJunkWallet, '-p', WALLET_PASSWORD]) + assert.notStrictEqual(code, 0) + assert.ok( + /Unable to open wallet file: invalid password/.test(out), + `expected the invalid-password message in:\n${out}` + ) + }) + + it('rejects a JSON file that is not marked encrypted or unencrypted', async () => { + const {code, out} = await runReceive([noEncryptedFlagWallet]) + assert.notStrictEqual(code, 0) + assert.ok( + /Invalid QRL address\/wallet file/.test(out), + `expected the invalid-wallet message in:\n${out}` + ) + }) +}) + +// The interactive branch -- an encrypted wallet with no -p -- cannot be driven +// through a pipe: cli-ux's hidden prompt shells out to `sh -c 'read -s'`, which +// spins forever when stdin is not a terminal. Running the command in-process +// with the prompt stubbed exercises the same branch without needing a pty. +describe('receive: a directory where a wallet file was expected', () => { + it('reports it rather than crashing on the read', async () => { + // existsSync says yes to a directory, so the command goes on to read it as a + // wallet. That read throws, and it used to escape as a raw EISDIR. + const {code, out} = await runReceive(['/tmp']) + assert.notStrictEqual(code, 0) + assert.ok(/Invalid QRL address\/wallet file/.test(out), out) + assert.ok(!/EISDIR/.test(out), out) + }) +}) + +describe('receive: password prompt', () => { + const {cli} = require('cli-ux') // eslint-disable-line global-require + const {Receive} = require('../../src/commands/receive') // eslint-disable-line global-require + + const root = path.join(__dirname, '..', '..') + const walletFile = '/tmp/recv-wallet-prompt.json' + let address + let originalPrompt + let asked + + before(async function makeWallet() { + this.timeout(120000) + await createWallet(['-f', walletFile, '-p', WALLET_PASSWORD]) + address = aes.decrypt(WALLET_PASSWORD, openWallet(walletFile).address) + }) + + beforeEach(() => { + asked = [] + originalPrompt = Object.getOwnPropertyDescriptor(cli, 'prompt') + Object.defineProperty(cli, 'prompt', { + configurable: true, + value: async (message, options) => { + asked.push({message, options}) + return WALLET_PASSWORD + }, + }) + }) + + afterEach(() => { + Object.defineProperty(cli, 'prompt', originalPrompt) + }) + + after(() => { + try { + fs.unlinkSync(walletFile) + } catch (error) { + // the wallet was never created; nothing to clean up + } + }) + + // Swallow the address banner and the QR block the command writes to stdout. + async function runInProcess(argv) { + const write = process.stdout.write.bind(process.stdout) + let out = '' + process.stdout.write = chunk => { + out += chunk.toString() + return true + } + try { + await Receive.run(argv, root) + } finally { + process.stdout.write = write + } + return out + } + + it('asks for the password when an encrypted wallet is given without -p', async () => { + const out = await runInProcess([walletFile]) + assert.deepStrictEqual( + asked.map(a => a.message), + ['Enter password for wallet file'], + 'expected exactly one password prompt' + ) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + assert.ok(out.includes(address), `expected the decrypted address in:\n${out}`) + }) +}) diff --git a/test/commands/search.test.js b/test/commands/search.test.js index 015d34b..61b336d 100644 --- a/test/commands/search.test.js +++ b/test/commands/search.test.js @@ -272,3 +272,207 @@ describe('search #12', () => { assert.notStrictEqual(exitCode, 0) }) }) + +// /////////////////////////////////////////////////////////////////////////// +// Offline cases. +// +// Nothing below reaches a node: each case either stops at the search-string +// switch or at a connection to a closed local port. `-g 127.0.0.1:1` is a port +// nothing listens on, so the CLI resolves it, fails to connect and exits — a +// deterministic failure that never leaves the machine. +// /////////////////////////////////////////////////////////////////////////// + +const DEAD_NODE = '127.0.0.1:1' +// A 78 character address with the leading Q stripped: search accepts this form too. +const ADDRESS_NO_Q = '000500b5ea246980f3ff4ee42f399e4a79598d6844e66373eb61ab59d1a1e6cfe8e963eb4bcd7f' + +// Run the CLI and capture what it said, rather than letting it write to the test output. +function runSearch(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +// Assert on the reason a case failed, not just on the exit code, so a command that starts +// failing somewhere else does not keep the test green. +async function searchRefuses(args, expected) { + const {code, out} = await runSearch(args) + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}`) + expected.forEach(pattern => { + assert.ok(pattern.test(out), `expected output to match ${pattern}\n--- actual ---\n${out}`) + }) +} + +describe('search offline', () => { + it('reports the custom endpoint it was given, and that it could not reach it', async () => { + await searchRefuses( + ['search', '15', '-g', DEAD_NODE], + [/Custom GRPC endpoint: \[127\.0\.0\.1:1\]/, /Block/, /Failed to connect to node/] + ) + }) + + it('reads a 78 character hex string as an address', async () => { + await searchRefuses( + ['search', ADDRESS_NO_Q, '-g', DEAD_NODE], + [/Address/, /Failed to connect to node/] + ) + }) + + it('does not fall back to a public node when the given endpoint is unreachable', async () => { + const {out} = await runSearch(['search', '15', '-g', DEAD_NODE]) + assert.ok( + !/automated\.theqrl\.org/.test(out), + `a failed custom endpoint must not be replaced by a public one\n--- actual ---\n${out}` + ) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// search: what the command does once a node has answered +// +// The suites above stop at a closed loopback port, so everything after the +// connection - the retry loop and the three kinds of lookup the command makes - +// only ran against a live node. These cases run the command in *this* process +// against a stub gRPC client instead. src/functions/grpc is swapped in the +// require cache for the moment it takes to require the command (it captures +// Qrlnode at require time), then the real module is put straight back. There is +// no server and no socket, and `search` only ever reads. +// /////////////////////////////////////////////////////////////////////////// + +// kleur colours by environment variable rather than by isTTY, so captured output +// still carries escape sequences. Strip them before matching. +const SEARCH_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +// Behaviour the stub should show for the test currently running. Reset per test. +let searchNode = {} +let searchCalls = [] +let searchConnectAttempts = 0 + +class SearchFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + searchConnectAttempts += 1 + if (searchNode.connectThrows) { + throw new Error(searchNode.connectThrows) + } + const connectsOn = searchNode.connectsOnAttempt === undefined ? 1 : searchNode.connectsOnAttempt + if (connectsOn !== 0 && searchConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + searchCalls.push({name, request}) + return searchNode.response === undefined ? {found: false} : searchNode.response + } +} + +const searchGrpcPath = require.resolve('../../src/functions/grpc') +const searchCommandPath = require.resolve('../../src/commands/search') + +const searchRealGrpcEntry = require.cache[searchGrpcPath] +require.cache[searchGrpcPath] = { + id: searchGrpcPath, + filename: searchGrpcPath, + path: require('path').dirname(searchGrpcPath), // eslint-disable-line global-require + loaded: true, + children: [], + paths: [], + exports: SearchFakeQrlNode, +} +const {Search} = require('../../src/commands/search') + +if (searchRealGrpcEntry) { + require.cache[searchGrpcPath] = searchRealGrpcEntry +} else { + delete require.cache[searchGrpcPath] +} + +// Run the command here, against the stub, capturing everything it prints +// (this.log and console.dir go to stdout, the ora spinner goes to stderr). +async function runSearchOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = (chunk) => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await Search.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + return {code, out: chunks.join('').replace(SEARCH_ANSI, '')} +} + +const A_TX_HASH = '9d3f463b300012292eac668768f2969125ae540b1cdef7c99f6fea448e736af8' + +describe('search: reading a node reply', () => { + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[searchCommandPath] + }) + + beforeEach(() => { + searchNode = {} + searchCalls = [] + searchConnectAttempts = 0 + }) + + it('retries the connection until the node answers', async () => { + searchNode = {connectsOnAttempt: 3} + const {code, out} = await runSearchOffline(['15', '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.strictEqual(searchConnectAttempts, 3) + assert.ok(/retry connection attempt: 0/.test(out), out) + assert.ok(/retry connection attempt: 1/.test(out), out) + }) + + it('asks for a block by its height', async () => { + const {code, out} = await runSearchOffline(['15', '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.strictEqual(searchCalls[0].name, 'GetObject') + // a block height goes over the wire as its decimal text, not as bytes + assert.strictEqual(searchCalls[0].request.query.toString(), '15') + assert.ok(/Unable to find block/.test(out), out) + }) + + it('asks for a transaction by its hash, as bytes', async () => { + const {code, out} = await runSearchOffline([A_TX_HASH, '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.strictEqual(searchCalls[0].request.query.toString('hex'), A_TX_HASH) + assert.ok(/Unable to find transaction/.test(out), out) + }) + + it('rejects an address the node was never going to recognise', async () => { + // 79 characters starting with Q, so it is routed as an address, but not a + // valid one: that has to be caught here rather than asked of the node. + const {code, out} = await runSearchOffline([`Q${'0'.repeat(78)}`, '-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.ok(/Invalid address given/.test(out), out) + assert.strictEqual(searchCalls.length, 0, 'nothing may be asked of the node for a bad address') + }) +}) diff --git a/test/commands/send-message.test.js b/test/commands/send-message.test.js index 12ef522..85ebfa0 100644 --- a/test/commands/send-message.test.js +++ b/test/commands/send-message.test.js @@ -1,6 +1,9 @@ const assert = require('assert') const {spawn} = require('child_process') +const crypto = require('crypto') const fs = require('fs') +const os = require('os') +const path = require('path') const testSetup = require('../test_setup') const processFlags = { @@ -284,8 +287,13 @@ describe('send-message #11', () => { }) }) +// An explicit fee of 0 is the same value the command uses when -f is omitted, so it has +// to be accepted and the run has to get all the way to the node. This case used to point +// at a bogus URL and assert only "non-zero if API is down" - which it was, but because +// `parseInt('0')` is falsy and the fee check rejected it long before any API was involved. describe('send-message #12', () => { let exitCode + let out = '' before(done => { const args = [ 'send-message', @@ -294,17 +302,27 @@ describe('send-message #12', () => { '-s', walletHexseed, '-i', '0', '-f', '0', - '-g', 'https://brooklyn.theqrl.org/nottheapi/', + '-g', '127.0.0.1:1', ] - const process = spawn('./bin/run', args, processFlags) - process.on('exit', code => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('exit', code => { exitCode = code done() }) }) - it('exit code should be non-0 if API is down', () => { + it('exit code should be non-0 when the node cannot be reached', () => { assert.notStrictEqual(exitCode, 0) }) + it('gets past the fee check, and fails at the node instead', () => { + assert.ok(!/Fee is invalid/.test(out), `a fee of 0 must be accepted\n--- actual ---\n${out}`) + assert.ok(/Failed to connect to node/.test(out), out) + }) }) // successful message send wallet file @@ -373,3 +391,564 @@ describe('send-message #15', () => { assert.strictEqual(exitCode, 0) }) }) + +// /////////////////////////////////////////////////////////////////////////// +// Offline cases. +// +// These stop at a validation gate or at a connection to a closed local port, so +// no transaction is ever built or pushed and no OTS key is consumed. The wallet +// is created here rather than taken from the shared fixtures, so the block runs +// on its own. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback: the CLI resolves it, fails to connect, and exits. +const DEAD_NODE = '127.0.0.1:1' + +let offlineDir +let offlineWallet +let offlineMnemonic +// A wallet in the legacy (pre-v2) encryption format holding an address that is not a QRL +// address. That format is unauthenticated, so a wrong password does not fail to decrypt, it +// yields something that is not an address — the only thing standing between that and a signing +// attempt is the address check send-message makes afterwards. Stored decrypted-to-nonsense +// rather than encrypted under another password so the case is deterministic. +let legacyWallet + +// Encrypt the way the retired `aes256` package did: key = sha256(password), AES-256-CTR, +// base64(iv || ciphertext). +function legacyEncrypt(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(password).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + return Buffer.concat([iv, cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]).toString('base64') +} + +// Run the CLI and capture what it said, rather than letting it write to the test output. +function runSendMessage(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +// Assert on the reason a case failed, not just on the exit code, so a command that starts +// failing somewhere else does not keep the test green. +async function sendMessageRefuses(args, expected) { + const {code, out} = await runSendMessage(args) + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}`) + expected.forEach(pattern => { + assert.ok(pattern.test(out), `expected output to match ${pattern}\n--- actual ---\n${out}`) + }) + return out +} + +describe('send-message offline', () => { + before(done => { + offlineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-send-message-')) + offlineWallet = path.join(offlineDir, 'wallet.json') + legacyWallet = path.join(offlineDir, 'legacy-wallet.json') + const child = spawn('./bin/run', ['create-wallet', '-3', '-h', '6', '-f', offlineWallet], processFlags) + child.on('exit', code => { + if (code !== 0) { + done(new Error(`create-wallet exited with code ${code}`)) + return + } + const created = openFile(offlineWallet)[0] + offlineMnemonic = created.mnemonic + fs.writeFileSync( + legacyWallet, + JSON.stringify([ + { + encrypted: true, + address: legacyEncrypt('the-right-password', 'this-is-not-a-qrl-address'), + addressB32: '', + pk: '', + hexseed: legacyEncrypt('the-right-password', created.hexseed), + mnemonic: legacyEncrypt('the-right-password', created.mnemonic), + height: created.height, + hashFunction: created.hashFunction, + signatureType: created.signatureType, + index: 0, + }, + ]) + ) + done() + }) + }) + + after(() => { + fs.rmSync(offlineDir, {recursive: true, force: true}) + }) + + it('opens a wallet from a mnemonic, then stops at the unreachable node', async () => { + const out = await sendMessageRefuses( + ['send-message', '-M', 'hello', '-s', offlineMnemonic, '-i', '0', '-f', '100', '-g', DEAD_NODE], + [/xmssPK returned/, /Failed to connect to node/] + ) + // Nothing may be signed or pushed once the node is unreachable. + assert.ok(!/Transaction signed/.test(out), `nothing may be signed\n--- actual ---\n${out}`) + assert.ok(!/Transaction submitted/.test(out), `nothing may be pushed\n--- actual ---\n${out}`) + }) + + it('does not fall back to a public node when the given endpoint is unreachable', async () => { + const out = await sendMessageRefuses( + ['send-message', '-M', 'hello', '-s', offlineMnemonic, '-i', '0', '-g', DEAD_NODE], + [/Failed to connect to node/] + ) + assert.ok( + !/automated\.theqrl\.org/.test(out), + `a failed custom endpoint must not be replaced by a public one\n--- actual ---\n${out}` + ) + }) + + it('refuses a legacy wallet whose decrypted address is not a QRL address', async () => { + await sendMessageRefuses( + ['send-message', '-M', 'hello', '-w', legacyWallet, '-p', 'the-right-password', '-i', '0', '-g', DEAD_NODE], + [/invalid password/] + ) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// send-message: what happens once a node has answered +// +// Everything above stops at a validation gate or at a closed loopback port, so +// the half of the command that runs after the node replies — the request it +// builds, the binding check that decides whether to sign, the push, and the +// transaction-id check on the way back — was unreachable. +// +// These cases run the command in *this* process against a stub gRPC client. +// src/functions/grpc is swapped in the require cache for the moment it takes to +// require the command (it captures Qrlnode at require time), then the real +// module is put straight back. There is no server and no socket. +// +// The signing is real, against a throwaway height-6 wallet, and the stub +// recomputes the transaction hash the way a node does, so a signature over +// something other than the request would show up here. Nothing reaches a +// network, so no on-chain OTS key is spent. +// /////////////////////////////////////////////////////////////////////////// + +const { + concatenateTypedArrays, + toBigendianUint64BytesUnsigned, + toUint8Vector, + binaryToBytes, +} = require('../../src/functions/tx-binding') + +// kleur colours by environment variable rather than by isTTY, so captured output +// still carries escape sequences. Strip them before matching. +const MSG_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +const MSG_RECIPIENT = 'Q000200ecffb27f3d7b11ccd048eb559277d64bb52bfda998341e66a9f11b2d07f6b2ee4f62c408' +const MSG_OTHER = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f3' + +// Behaviour the stub should show for the test currently running. Reset per test. +let msgNode = {} +let msgCalls = [] +let msgConnectAttempts = 0 + +// Rebuild the transaction hash from the signed transaction the command pushed, +// the way QRL core does for a MessageTransaction: +// preimage = master_addr || fee || message_hash || addr_to +// hash = sha256(sha256(preimage) || signature || public key) +function messageTransactionHash(signedTx) { + const parts = [ + toBigendianUint64BytesUnsigned(parseInt(signedTx.fee, 10)), + Uint8Array.from(Buffer.from(signedTx.message.message_hash)), + Uint8Array.from(Buffer.from(signedTx.message.addr_to || [])), + ] + const preimage = concatenateTypedArrays(Uint8Array, ...parts) + const digest = QRLLIB.sha2_256(toUint8Vector(preimage)) // eslint-disable-line no-undef + const whole = concatenateTypedArrays( + Uint8Array, + binaryToBytes(digest), + Uint8Array.from(signedTx.signature), + Uint8Array.from(signedTx.public_key) + ) + // eslint-disable-next-line no-undef + return Buffer.from(QRLLIB.bin2hstr(QRLLIB.sha2_256(toUint8Vector(whole))), 'hex') +} + +// What an honest node returns for GetMessageTxn: the request echoed back. +const buildMessageResponse = request => ({ + extended_transaction_unsigned: { + tx: { + master_addr: Buffer.from(request.master_addr), + fee: String(request.fee), + message: { + message_hash: Buffer.from(request.message), + addr_to: Buffer.from(request.addr_to), + }, + }, + }, +}) + +class MessageFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + msgConnectAttempts += 1 + if (msgNode.connectThrows) { + throw new Error(msgNode.connectThrows) + } + const connectsOn = msgNode.connectsOnAttempt === undefined ? 1 : msgNode.connectsOnAttempt + if (connectsOn !== 0 && msgConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + msgCalls.push({name, request}) + if (name === 'GetMessageTxn') { + const built = buildMessageResponse(request) + return msgNode.tamper ? msgNode.tamper(built) : built + } + if (msgNode.pushResponse) { + return msgNode.pushResponse + } + const hash = messageTransactionHash(request.transaction_signed) + return {tx_hash: msgNode.wrongHash ? Buffer.alloc(32, 0x11) : hash} + } +} + +const msgGrpcPath = require.resolve('../../src/functions/grpc') +const msgCommandPath = require.resolve('../../src/commands/send-message') + +const msgRealGrpcEntry = require.cache[msgGrpcPath] +require.cache[msgGrpcPath] = { + id: msgGrpcPath, + filename: msgGrpcPath, + path: path.dirname(msgGrpcPath), + loaded: true, + children: [], + paths: [], + exports: MessageFakeQrlNode, +} +const {SendMessage} = require('../../src/commands/send-message') + +if (msgRealGrpcEntry) { + require.cache[msgGrpcPath] = msgRealGrpcEntry +} else { + delete require.cache[msgGrpcPath] +} + +// Run send-message here, against the stub, capturing everything it prints (this.log +// and console.log go to stdout, the ora spinners go to stderr). run() awaits the whole +// signing and pushing sequence, so an exit inside it arrives as a thrown ExitError. +async function runSendMessageInProcess(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = chunk => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await SendMessage.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + // kleur colours by environment variable rather than by isTTY, so the captured output + // still carries escape sequences here. Strip them so the assertions read as the text a + // person would see. + return {code, out: chunks.join('').replace(MSG_ANSI, '')} +} + +// cli-ux exposes `prompt` as a getter, so the wallet-password prompt has to be +// redefined rather than assigned. +const msgCliUx = require('cli-ux').cli // eslint-disable-line import/order + +function stubMsgPassword(password) { + const saved = Object.getOwnPropertyDescriptor(msgCliUx, 'prompt') + const asked = [] + Object.defineProperty(msgCliUx, 'prompt', { + configurable: true, + get: () => async (message, options) => { + asked.push({message, options}) + return password + }, + }) + return {asked, restore: () => Object.defineProperty(msgCliUx, 'prompt', saved)} +} + +async function runSendMessageWithPassword(argv, password) { + const stub = stubMsgPassword(password) + try { + const result = await runSendMessageInProcess(argv) + return {...result, asked: stub.asked} + } finally { + stub.restore() + } +} + +describe('send-message: signing and pushing what a node returned', () => { + let nodeDir + let nodeWallet + let nodeBadWallet + + before(function createNodeWallet(done) { + this.timeout(120000) + nodeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-send-message-node-')) + nodeWallet = path.join(nodeDir, 'wallet.json') + nodeBadWallet = path.join(nodeDir, 'not-a-wallet.json') + fs.writeFileSync(nodeBadWallet, 'this file is not JSON at all') + const child = spawn('./bin/run', ['create-wallet', '-3', '-h', '6', '-f', nodeWallet], processFlags) + child.on('exit', code => (code === 0 ? done() : done(new Error(`create-wallet exited ${code}`)))) + }) + + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[msgCommandPath] + fs.rmSync(nodeDir, {recursive: true, force: true}) + }) + + beforeEach(() => { + msgNode = {} + msgCalls = [] + msgConnectAttempts = 0 + }) + + const msgArgs = (extra = []) => [ + '-M', 'hello chain', + '-r', MSG_RECIPIENT, + '-i', '0', + '-w', nodeWallet, + '-g', DEAD_NODE, + ...extra, + ] + + it('asks the node to build the message it was given', async function builds() { + this.timeout(120000) + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 0, out) + const build = msgCalls.find(c => c.name === 'GetMessageTxn') + assert.ok(build, `no GetMessageTxn call was made\n--- output ---\n${out}`) + assert.strictEqual(Buffer.from(build.request.message).toString(), 'hello chain') + assert.strictEqual(`Q${Buffer.from(build.request.addr_to).toString('hex')}`, MSG_RECIPIENT) + assert.strictEqual(build.request.fee, 0) + }) + + it('signs, pushes, and reports the id the node gave back', async function signs() { + this.timeout(120000) + const {code, out} = await runSendMessageInProcess(msgArgs(['-f', '100'])) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + const push = msgCalls.find(c => c.name === 'PushTransaction') + assert.ok(push.request.transaction_signed.signature.length > 0, 'pushed without a signature') + assert.strictEqual(push.request.transaction_signed.fee, '100') + // The id can only match if the command signed the message it asked the node to build. + const hash = messageTransactionHash(push.request.transaction_signed).toString('hex') + assert.ok(out.includes(`transaction ID: ${hash}`), out) + }) + + it('reports a wallet file it cannot read at all as an unusable file', async function badWallet() { + this.timeout(120000) + // The other side of the password fix: a file that genuinely will not parse still has + // to be called an unusable file, not a bad password. + const {code, out} = await runSendMessageInProcess( + ['-M', 'hello chain', '-i', '0', '-w', nodeBadWallet, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to open wallet file: invalid wallet file/.test(out), out) + assert.ok(!/invalid password/.test(out), `the password was never the problem\n--- actual ---\n${out}`) + assert.strictEqual(msgCalls.length, 0, 'no node is contacted for a wallet that never opened') + }) + + it('reports a hexseed the XMSS library cannot use, rather than failing silently', async function badSeed() { + this.timeout(120000) + const {code, out} = await runSendMessageInProcess( + ['-M', 'hello chain', '-i', '0', '-s', '020200cb68ca52ae4aff1d2ac10a2cc03f2325b95ab4610d2c6fd2af684aa1427766ac0b96b05942734d254fb9dba5fcb139HG', '-g', DEAD_NODE] + ) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to recreate XMSS wallet object/.test(out), out) + assert.strictEqual(msgCalls.length, 0, 'no node is contacted when the key cannot be rebuilt') + }) + + it('accepts an explicit fee of 0, the same value it uses when -f is omitted', async function zeroFee() { + this.timeout(120000) + const {code, out} = await runSendMessageInProcess(msgArgs(['-f', '0'])) + assert.strictEqual(code, 0, out) + assert.strictEqual(msgCalls.find(c => c.name === 'GetMessageTxn').request.fee, 0) + }) + + it('sends a message with no recipient at all', async function noRecipient() { + this.timeout(120000) + // A message with no addr_to is broadcast rather than addressed; the empty + // recipient still has to be bound, or a node could add one. + const {code, out} = await runSendMessageInProcess( + ['-M', 'to nobody', '-i', '0', '-w', nodeWallet, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 0, out) + const build = msgCalls.find(c => c.name === 'GetMessageTxn') + assert.deepStrictEqual(build.request.addr_to, []) + }) + + it('retries the connection until the node answers', async function retries() { + this.timeout(120000) + msgNode = {connectsOnAttempt: 3} + const {code} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 0) + assert.strictEqual(msgConnectAttempts, 3) + }) + + it('links to the mainnet explorer when sending on mainnet', async function mainnet() { + this.timeout(120000) + const {code, out} = await runSendMessageInProcess( + ['-M', 'hello chain', '-i', '0', '-w', nodeWallet, '-m'] + ) + assert.strictEqual(code, 0, out) + assert.ok(/https:\/\/explorer\.theqrl\.org\/tx\/[0-9a-f]{64}/.test(out), out) + }) + + it('links to the testnet explorer when sending on testnet', async function testnet() { + this.timeout(120000) + const {code, out} = await runSendMessageInProcess( + ['-M', 'hello chain', '-i', '0', '-w', nodeWallet, '-t'] + ) + assert.strictEqual(code, 0, out) + assert.ok(/https:\/\/testnet-explorer\.theqrl\.org\/tx\/[0-9a-f]{64}/.test(out), out) + }) + + it('refuses to sign a response that rewrote the message', async function tamperedMessage() { + this.timeout(120000) + msgNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.message.message_hash = Buffer.from('something else entirely') + return response + }, + } + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different message/.test(out), out) + assert.ok(/Nothing was signed and no OTS key was used/.test(out), out) + assert.strictEqual(msgCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('refuses to sign a response that redirected the message', async function tamperedRecipient() { + this.timeout(120000) + msgNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.message.addr_to = Buffer.from(MSG_OTHER.substring(1), 'hex') + return response + }, + } + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different recipient/.test(out), out) + }) + + it('refuses to sign a response that inflated the fee', async function tamperedFee() { + this.timeout(120000) + msgNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.fee = '100000000' + return response + }, + } + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different fee/.test(out), out) + }) + + it('reports a node that rejects the push', async function pushRejected() { + this.timeout(120000) + msgNode = {pushResponse: {error_code: 'INVALID', error_description: 'OTS key reused'}} + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/OTS key reused/.test(out), out) + }) + + it('refuses a transaction id that is not the one it signed', async function hashMismatch() { + this.timeout(120000) + msgNode = {wrongHash: true} + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/Node transaction hash 1111/.test(out), out) + }) + + it('reports a connection that fails outright', async function connectFailed() { + this.timeout(120000) + msgNode = {connectThrows: 'no route to host'} + const {code, out} = await runSendMessageInProcess(msgArgs()) + assert.strictEqual(code, 1, out) + assert.strictEqual(msgCalls.length, 0, 'nothing may be asked of a node that never connected') + }) + describe('wallet password', () => { + let encWallet + const WALLET_PASSWORD = 'prompted-password' + + before(() => { + const aes = require('../../src/utils/aes') // eslint-disable-line global-require + const [created] = JSON.parse(fs.readFileSync(nodeWallet)) + encWallet = path.join(nodeDir, 'enc-wallet.json') + fs.writeFileSync( + encWallet, + JSON.stringify([ + { + encrypted: true, + address: aes.encrypt(WALLET_PASSWORD, created.address), + hexseed: aes.encrypt(WALLET_PASSWORD, created.hexseed), + }, + ]) + ) + }) + + it('asks for the password when --password is not given', async function passwordPrompt() { + this.timeout(120000) + // Without this branch an encrypted wallet would only be usable with the + // password on the command line, where it lands in shell history. + const {code, out, asked} = await runSendMessageWithPassword( + ['-M', 'hello chain', '-i', '0', '-w', encWallet, '-g', DEAD_NODE], + WALLET_PASSWORD + ) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.length, 1) + assert.ok(/Enter password for wallet file/.test(asked[0].message)) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + }) + + it('refuses a wrong password typed at the prompt', async function wrongPassword() { + this.timeout(120000) + const {code, out} = await runSendMessageWithPassword( + ['-M', 'hello chain', '-i', '0', '-w', encWallet, '-g', DEAD_NODE], + 'not-the-password' + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(msgCalls.length, 0, 'nothing is signed or sent for a wallet that never opened') + }) + }) + + it('reports a signing failure that is not a binding failure', async function badOts() { + this.timeout(120000) + // OTS index 999 does not exist in a height-6 tree, so the response binds + // cleanly and it is xmss.sign() that fails. + const {code, out} = await runSendMessageInProcess( + ['-M', 'hello chain', '-i', '999', '-w', nodeWallet, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(msgCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) +}) diff --git a/test/commands/send.test.js b/test/commands/send.test.js index fba1223..597aa81 100644 --- a/test/commands/send.test.js +++ b/test/commands/send.test.js @@ -1,7 +1,14 @@ const assert = require('assert') const {spawn} = require('child_process') +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') const testSetup = require('../test_setup') +// Suites that need a reachable node (skipped in offline mode). These push a signed transaction +// to the network, so they must never run as part of an offline/no-node test pass. +const describeOnline = process.env.QRL_TEST_OFFLINE === 'true' ? describe.skip : describe + const processFlags = { detached: true, @@ -685,7 +692,7 @@ describe('send #3a', () => { // load from a file -describe('send #3b', () => { +describeOnline('send #3b', () => { let exitCode before(done => { const args = [ @@ -848,4 +855,1254 @@ describe('send #3e', () => { assert.strictEqual(exitCode, 0) }) }) -*/ \ No newline at end of file +*/ +// /////////////////////////////////////////////////////////////////////////// +// send: offline coverage suite +// +// `send` has three modes and only one of them needs a node: +// +// * online - asks a node to build the transaction, signs it, pushes it +// * --savetofile/-T - builds AND signs the transaction entirely locally, writes JSON +// * --loadfromfile/-F - reads a signed transaction back and pushes it +// +// Everything below stays inside the offline half: each case either stops at a +// validation gate, signs into a file under /tmp, or dies connecting to a closed +// loopback port. Nothing contacts mainnet or testnet and no transaction is ever +// pushed. +// +// OTS keys: these tests sign with wallets this file creates in its own before() +// hook (/tmp/send-wallet*.json), never with a shared fixture, so a signature +// here can never burn a key another test or another person depends on. +// +// Assertions are on the message the command printed, not just the exit code: the +// validation ladder in `send` has a dozen different ways to exit non-zero and a +// test that only checks the code keeps passing when the command starts failing +// for the wrong reason. +// /////////////////////////////////////////////////////////////////////////// + +// A closed port on loopback. The CLI resolves it, fails to connect and exits. +// Deterministic, and the packet never leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +const OFFLINE_WALLET = '/tmp/send-wallet.json' +const OFFLINE_ENC_WALLET = '/tmp/send-wallet-enc.json' +// A wallet in the pre-v2 (`aes256` package) encryption format. That format is +// unauthenticated, so a wrong password yields garbage instead of throwing - the +// only way to reach send.js's "invalid password" branch, which validates the +// decrypted address rather than trusting the decryption to fail. +const OFFLINE_LEGACY_WALLET = '/tmp/send-wallet-legacy.json' +const OFFLINE_PASSWORD = 'testpassword' +const OFFLINE_OUTPUTS = '/tmp/send-outputs.json' +const OFFLINE_NOT_JSON = '/tmp/send-not-json.txt' +const OFFLINE_TX_OUT = '/tmp/send-offline-tx.json' + +const RECIPIENT_A = 'Q000200ecffb27f3d7b11ccd048eb559277d64bb52bfda998341e66a9f11b2d07f6b2ee4f62c408' +const RECIPIENT_B = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f3' + +// Run the CLI and capture what it said rather than letting it write to test output. +function runSend(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +function sendRefuses(args, expected) { + return runSend(args).then(({code, out}) => { + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}\n--- output ---\n${out}`) + assert.ok(expected.test(out), `expected output to match ${expected}\n--- actual ---\n${out}`) + }) +} + +function sendAccepts(args, expected) { + return runSend(args).then(({code, out}) => { + assert.strictEqual(code, 0, `expected a zero exit for: ${args.join(' ')}\n--- output ---\n${out}`) + assert.ok(expected.test(out), `expected output to match ${expected}\n--- actual ---\n${out}`) + }) +} + +function createOfflineWallet(file, password) { + return new Promise((resolve, reject) => { + const args = ['create-wallet', '-h', '6', '-f', file] + if (password) { + args.push('-p', password) + } + const child = spawn('./bin/run', args, {stdio: ['ignore', 'ignore', 'ignore']}) + child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`create-wallet exited ${code}`)))) + child.on('error', reject) + }) +} + +// Legacy `aes256` blob: key = sha256(password), AES-256-CTR, base64(iv || ciphertext). +// Matches the format src/utils/aes.js still reads for backwards compatibility. +function legacyEncrypt(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(String(password)).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]) + return Buffer.concat([iv, ciphertext]).toString('base64') +} + +describe('send: offline coverage', () => { + let plainWallet + + before(async function createSendFixtures() { + this.timeout(180000) + await createOfflineWallet(OFFLINE_WALLET, null) + await createOfflineWallet(OFFLINE_ENC_WALLET, OFFLINE_PASSWORD) + ;[plainWallet] = JSON.parse(fs.readFileSync(OFFLINE_WALLET)) + + fs.writeFileSync( + OFFLINE_LEGACY_WALLET, + JSON.stringify([ + { + encrypted: true, + address: legacyEncrypt(OFFLINE_PASSWORD, plainWallet.address), + addressB32: legacyEncrypt(OFFLINE_PASSWORD, plainWallet.addressB32), + pk: legacyEncrypt(OFFLINE_PASSWORD, plainWallet.pk), + hexseed: legacyEncrypt(OFFLINE_PASSWORD, plainWallet.hexseed), + mnemonic: legacyEncrypt(OFFLINE_PASSWORD, plainWallet.mnemonic), + height: plainWallet.height, + hashFunction: plainWallet.hashFunction, + signatureType: plainWallet.signatureType, + index: plainWallet.index, + }, + ]) + ) + + fs.writeFileSync( + OFFLINE_OUTPUTS, + JSON.stringify({tx: [{to: RECIPIENT_A, shor: '10'}, {to: RECIPIENT_B, shor: '15'}]}) + ) + fs.writeFileSync(OFFLINE_NOT_JSON, 'this file is not JSON at all') + }) + + after(() => { + [OFFLINE_WALLET, OFFLINE_ENC_WALLET, OFFLINE_LEGACY_WALLET, OFFLINE_OUTPUTS, OFFLINE_NOT_JSON, OFFLINE_TX_OUT].forEach( + file => { + try { + fs.unlinkSync(file) + } catch (err) { + // fixture already gone; nothing to clean up + } + } + ) + }) + + // ------------------------------------------------------------------------- + // What each prompt falls back to through a pipe. Every one of these is a + // question the command would ask at a terminal; with no terminal to ask, it + // has to name the missing flag rather than prompt into a closed stdin. + // ------------------------------------------------------------------------- + describe('missing arguments, with no terminal to ask at', () => { + it('names the missing quantity', async () => { + await sendRefuses( + ['send', '-r', RECIPIENT_A, '-i', '0', '-w', OFFLINE_WALLET], + /Missing required argument: quantity/ + ) + }) + + it('names the missing OTS index', async () => { + await sendRefuses( + ['send', '1', '-r', RECIPIENT_A, '-w', OFFLINE_WALLET], + /Missing required flag: --otsindex/ + ) + }) + + it('names the missing wallet or hexseed', async () => { + await sendRefuses(['send', '1', '-r', RECIPIENT_A, '-i', '0'], /Missing sender wallet file/) + }) + }) + + // ------------------------------------------------------------------------- + // Recipient/amount parsing: -r, -j and -R all end up in the same `output.tx` + // ------------------------------------------------------------------------- + + describe('recipient sources', () => { + it('converts a Quanta amount to Shor when -s is not given', async function quantaToShor() { + this.timeout(120000) + // 2 Quanta must be written out as 2000000000 Shor. Without -s the amount goes + // through BigNumber; this is the only path that multiplies by 10^9. + await sendAccepts( + ['send', '2', '-r', RECIPIENT_A, '-w', OFFLINE_WALLET, '-i', '0', '-T', OFFLINE_TX_OUT], + /amount in shor: 2000000000/ + ) + const saved = JSON.parse(fs.readFileSync(OFFLINE_TX_OUT)) + assert.strictEqual(saved.tx.transfer.amounts[0], '2000000000') + assert.strictEqual(saved.addr_from, plainWallet.address) + assert.strictEqual(saved.ots_key, '0') + }) + + it('signs a multi-recipient transaction passed as a JSON object with -j', async function jsonObject() { + this.timeout(120000) + await sendAccepts( + [ + 'send', + // NOTE: the positional `quantity` is still required even though -j carries every + // amount. It is parsed and then ignored on this path. + '0', + '-j', + JSON.stringify({tx: [{to: RECIPIENT_A, shor: '10'}, {to: RECIPIENT_B, shor: '15'}]}), + '-w', + OFFLINE_WALLET, + '-i', + '1', + '-T', + OFFLINE_TX_OUT, + ], + /Transaction data has been saved to the file/ + ) + const saved = JSON.parse(fs.readFileSync(OFFLINE_TX_OUT)) + assert.deepStrictEqual(saved.tx.transfer.amounts, ['10', '15']) + assert.strictEqual(saved.tx.transfer.addrs_to.length, 2) + }) + + it('signs a multi-recipient transaction read from a JSON file with -R', async function recipientFile() { + this.timeout(120000) + await sendAccepts( + ['send', '0', '-R', OFFLINE_OUTPUTS, '-w', OFFLINE_WALLET, '-i', '2', '-T', OFFLINE_TX_OUT], + /Transaction data has been saved to the file/ + ) + const saved = JSON.parse(fs.readFileSync(OFFLINE_TX_OUT)) + assert.deepStrictEqual(saved.tx.transfer.amounts, ['10', '15']) + }) + + it('rejects a -R file that is not JSON', async function recipientFileNotJson() { + this.timeout(60000) + await sendRefuses( + ['send', '0', '-R', OFFLINE_NOT_JSON, '-w', OFFLINE_WALLET, '-i', '3', '-T', OFFLINE_TX_OUT], + /Unable to send: json object passed with -j contains invalid output data/ + ) + }) + + it('rejects a -j object with no tx array', async function jsonNoTxArray() { + this.timeout(60000) + await sendRefuses( + ['send', '10', '-j', '{"outputs":[]}', '-w', OFFLINE_WALLET, '-i', '3', '-T', OFFLINE_TX_OUT], + /array is undefined/ + ) + }) + + it('rejects a -j object with an empty tx array', async function jsonEmptyTxArray() { + this.timeout(60000) + await sendRefuses( + ['send', '10', '-j', '{"tx":[]}', '-w', OFFLINE_WALLET, '-i', '3', '-T', OFFLINE_TX_OUT], + /No transactions found: length of array is 0/ + ) + }) + + it('rejects a -R file whose outputs have no tx array', async function recipientFileNoTx() { + this.timeout(60000) + await sendRefuses( + ['send', '0', '-R', OFFLINE_ENC_WALLET, '-w', OFFLINE_WALLET, '-i', '3', '-T', OFFLINE_TX_OUT], + /json file contains invalid output data/ + ) + }) + }) + + // ------------------------------------------------------------------------- + // Wallet handling + // ------------------------------------------------------------------------- + + describe('sender wallet', () => { + it('opens an unencrypted wallet file and reports the sending address', async function plainWalletFile() { + this.timeout(120000) + await sendAccepts( + ['send', '1', '-s', '-r', RECIPIENT_A, '-w', OFFLINE_WALLET, '-i', '4', '-T', OFFLINE_TX_OUT], + new RegExp(`Sending from: ${plainWallet.address}`) + ) + }) + + it('decrypts a v2 encrypted wallet with the right password', async function encWalletFile() { + this.timeout(120000) + await sendAccepts( + [ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_ENC_WALLET, + '-p', + OFFLINE_PASSWORD, + '-i', + '5', + '-T', + OFFLINE_TX_OUT, + ], + /Transaction data has been saved to the file/ + ) + }) + + it('decrypts a legacy-format encrypted wallet with the right password', async function legacyWalletFile() { + this.timeout(120000) + await sendAccepts( + [ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_LEGACY_WALLET, + '-p', + OFFLINE_PASSWORD, + '-i', + '6', + '-T', + OFFLINE_TX_OUT, + ], + new RegExp(`Sending from: ${plainWallet.address}`) + ) + }) + + it('refuses a legacy-format wallet when the password is wrong', async function legacyWalletBadPassword() { + this.timeout(60000) + // The legacy format is unauthenticated, so decryption "succeeds" and returns + // garbage. send.js has to notice the plaintext is not a QRL address. + await sendRefuses( + [ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_LEGACY_WALLET, + '-p', + 'not-the-password', + '-i', + '6', + '-T', + OFFLINE_TX_OUT, + ], + /Unable to open wallet file: invalid password/ + ) + }) + + it('signs from a raw hexseed', async function hexseedSender() { + this.timeout(120000) + await sendAccepts( + ['send', '1', '-s', '-r', RECIPIENT_A, '-h', plainWallet.hexseed, '-i', '7', '-T', OFFLINE_TX_OUT], + /Transaction data has been saved to the file/ + ) + }) + + it('signs from a mnemonic phrase', async function mnemonicSender() { + this.timeout(120000) + await sendAccepts( + ['send', '1', '-s', '-r', RECIPIENT_A, '-h', plainWallet.mnemonic, '-i', '8', '-T', OFFLINE_TX_OUT], + /Transaction data has been saved to the file/ + ) + }) + }) + + // ------------------------------------------------------------------------- + // Fee, message and signing + // ------------------------------------------------------------------------- + + describe('fee, message and signing', () => { + it('accepts an explicit fee in Shor', async function explicitFee() { + this.timeout(120000) + await sendAccepts( + ['send', '1', '-s', '-r', RECIPIENT_A, '-w', OFFLINE_WALLET, '-i', '9', '-f', '100', '-T', OFFLINE_TX_OUT], + /Fee: 100 Shor/ + ) + const saved = JSON.parse(fs.readFileSync(OFFLINE_TX_OUT)) + assert.strictEqual(saved.tx.fee, '100') + }) + + it('attaches message data to the saved transaction', async function messageData() { + this.timeout(120000) + const message = 'offline coverage message' + await sendAccepts( + [ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_WALLET, + '-i', + '10', + '-M', + message, + '-T', + OFFLINE_TX_OUT, + ], + new RegExp(`Message Length\\s+${message.length}`) + ) + const saved = JSON.parse(fs.readFileSync(OFFLINE_TX_OUT)) + // message_data survives into the file as the same bytes that were signed + assert.deepStrictEqual( + Buffer.from(saved.tx.transfer.message_data).toString('utf8'), + message + ) + }) + + it('accepts a message of exactly 80 bytes', async function messageAtLimit() { + this.timeout(120000) + const message = 'a'.repeat(80) + await sendAccepts( + [ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_WALLET, + '-i', + '11', + '-M', + message, + '-T', + OFFLINE_TX_OUT, + ], + /Message Length\s+80/ + ) + }) + + it('fails cleanly when the OTS index is beyond the end of the tree', async function otsOutOfRange() { + this.timeout(120000) + // A height-6 wallet has 64 OTS keys (0-63). Asking for 64 makes QRLLIB refuse + // to sign; nothing must be written and the command must not claim success. + await sendRefuses( + ['send', '1', '-s', '-r', RECIPIENT_A, '-w', OFFLINE_WALLET, '-i', '64', '-T', OFFLINE_TX_OUT], + /Failed to sign transaction/ + ) + }) + + it('refuses to write the signed transaction to an unwritable path', async function unwritableTarget() { + this.timeout(120000) + await sendRefuses( + [ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_WALLET, + '-i', + '12', + '-T', + path.join('/proc/self/no-such-dir', 'tx.json'), + ], + /Unable to save data to TX file/ + ) + }) + }) + + // ------------------------------------------------------------------------- + // Node connection failures (closed loopback port, never a real endpoint) + // ------------------------------------------------------------------------- + + describe('node connection', () => { + it('reports a connection failure instead of signing when the node is unreachable', async function deadNodeSend() { + this.timeout(120000) + // Online mode: the transaction is built by the node, so an unreachable node + // must stop us before any OTS key is consumed. + const {code, out} = await runSend([ + 'send', + '1', + '-s', + '-r', + RECIPIENT_A, + '-w', + OFFLINE_WALLET, + '-i', + '13', + '-g', + DEAD_NODE, + ]) + assert.notStrictEqual(code, 0) + assert.ok(/Failed to connect to node/.test(out), `--- actual ---\n${out}`) + assert.ok( + !/Transaction signed with OTS key/.test(out), + `nothing may be signed when the node is unreachable\n--- actual ---\n${out}` + ) + }) + + it('reports a connection failure when pushing a saved transaction with -F', async function deadNodeLoad() { + this.timeout(120000) + // Build a signed transaction offline first, then try to push it at a closed port. + await sendAccepts( + ['send', '1', '-s', '-r', RECIPIENT_A, '-w', OFFLINE_WALLET, '-i', '14', '-T', OFFLINE_TX_OUT], + /Transaction data has been saved to the file/ + ) + await sendRefuses(['send', '-F', OFFLINE_TX_OUT, '-g', DEAD_NODE], /Failed to connect to node/) + }) + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// send: what happens once a node has answered +// +// The suites above stop at a validation gate, at a closed loopback port, or at +// --savetofile, which never contacts a node. What was left uncovered is the +// half of the command that runs after the node replies: the request it builds, +// the binding check that decides whether to sign at all, the push, and the +// transaction-id check on the way back. +// +// These cases run the command in *this* process against a stub gRPC client. +// src/functions/grpc is swapped in the require cache for the moment it takes to +// require the command (it captures Qrlnode at require time), then the real +// module is put straight back. There is no server and no socket. +// +// The signing is real: a throwaway height-6 wallet is used to produce genuine +// XMSS signatures, and the stub recomputes the transaction hash the way a node +// would, so a signature that did not match the request would be visible here. +// Nothing reaches a network, so no on-chain OTS key is spent. +// /////////////////////////////////////////////////////////////////////////// + +const { + concatenateTypedArrays, + toBigendianUint64BytesUnsigned, + toUint8Vector, + binaryToBytes, +} = require('../../src/functions/tx-binding') + +// kleur colours by environment variable rather than by isTTY, so captured output +// still carries escape sequences. Strip them before matching. +const SEND_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +const SEND_NODE_WALLET = '/tmp/send-node-wallet.json' +const SEND_NODE_TX_FILE = '/tmp/send-node-offline-tx.json' +const SEND_NODE_NOT_JSON = '/tmp/send-node-not-json.txt' +const SEND_NODE_ENC_WALLET = '/tmp/send-node-enc-wallet.json' +const SEND_NODE_PASSWORD = 'node-suite-password' + +// Behaviour the stub should show for the test currently running. Reset per test. +let sendNode = {} +let sendCalls = [] +let sendConnectAttempts = 0 + +// Rebuild the transaction hash from the signed transaction the command pushed, +// exactly as QRL core does: sha256(digest) over the preimage, then +// sha256(digest || signature || public key). Doing it here rather than echoing a +// value back means the success case only passes if the command signed the +// transaction it said it was signing. +function nodeTransactionHash(signedTx) { + const parts = [toBigendianUint64BytesUnsigned(parseInt(signedTx.fee, 10))] + if (signedTx.transfer.message_data) { + parts.push(Uint8Array.from(Buffer.from(signedTx.transfer.message_data))) + } + signedTx.transfer.addrs_to.forEach((addr, i) => { + parts.push(Uint8Array.from(Buffer.from(addr))) + parts.push(toBigendianUint64BytesUnsigned(signedTx.transfer.amounts[i])) + }) + const preimage = concatenateTypedArrays(Uint8Array, ...parts) + const digest = QRLLIB.sha2_256(toUint8Vector(preimage)) // eslint-disable-line no-undef + const whole = concatenateTypedArrays( + Uint8Array, + binaryToBytes(digest), + Uint8Array.from(signedTx.signature), + Uint8Array.from(signedTx.public_key) + ) + // eslint-disable-next-line no-undef + return Buffer.from(QRLLIB.bin2hstr(QRLLIB.sha2_256(toUint8Vector(whole))), 'hex') +} + +// What an honest node returns for TransferCoins: the request echoed back in the +// shape the proto loader produces. +function buildTransferResponse(request) { + const transfer = { + addrs_to: request.addresses_to.map(item => Buffer.from(item)), + amounts: request.amounts.slice(), + } + if (request.message_data) { + transfer.message_data = Buffer.from(request.message_data) + } + return { + extended_transaction_unsigned: { + tx: { + fee: String(request.fee), + public_key: {}, + signature: {}, + transaction_hash: {}, + transfer, + }, + }, + } +} + +class SendFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + sendConnectAttempts += 1 + if (sendNode.connectThrows) { + throw new Error(sendNode.connectThrows) + } + const connectsOn = sendNode.connectsOnAttempt === undefined ? 1 : sendNode.connectsOnAttempt + if (connectsOn !== 0 && sendConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + sendCalls.push({name, request}) + if (name === 'TransferCoins') { + const built = buildTransferResponse(request) + return sendNode.tamper ? sendNode.tamper(built) : built + } + // PushTransaction + if (sendNode.pushResponse) { + return sendNode.pushResponse + } + const signedTx = request.transaction_signed + // A transaction loaded from a file already carries the hash it was signed + // with; one built here has to have it recomputed. + const hash = + typeof signedTx.transaction_hash === 'string' + ? Buffer.from(signedTx.transaction_hash, 'hex') + : nodeTransactionHash(signedTx) + return {tx_hash: sendNode.wrongHash ? Buffer.alloc(32, 0x11) : hash} + } +} + +const sendGrpcPath = require.resolve('../../src/functions/grpc') +const sendCommandPath = require.resolve('../../src/commands/send') + +const sendRealGrpcEntry = require.cache[sendGrpcPath] +require.cache[sendGrpcPath] = { + id: sendGrpcPath, + filename: sendGrpcPath, + path: path.dirname(sendGrpcPath), + loaded: true, + children: [], + paths: [], + exports: SendFakeQrlNode, +} +const {Send} = require('../../src/commands/send') + +if (sendRealGrpcEntry) { + require.cache[sendGrpcPath] = sendRealGrpcEntry +} else { + delete require.cache[sendGrpcPath] +} + +// Run send here, against the stub, capturing everything it prints (this.log +// and console.log go to stdout, the ora spinners go to stderr). run() awaits the whole +// signing and pushing sequence, so an exit inside it arrives as a thrown ExitError. +async function runSendInProcess(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = chunk => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await Send.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + // kleur colours by environment variable rather than by isTTY, so the captured output + // still carries escape sequences here. Strip them so the assertions read as the text a + // person would see. + return {code, out: chunks.join('').replace(SEND_ANSI, '')} +} + +// The prompts only run when stdin and stdout are terminals, so through a pipe +// that whole half of the command is unreachable. Fake the terminal, and stand in +// for the two prompt libraries send uses: `prompts` (required lazily inside +// run()) and cli-ux's `cli.prompt` for the wallet password. +const sendCliUx = require('cli-ux').cli // eslint-disable-line import/order + +function stubSendPrompts(fake) { + const promptsPath = require.resolve('prompts') + const saved = require.cache[promptsPath] + const Module = require('module') // eslint-disable-line global-require + const stub = new Module(promptsPath, null) + stub.filename = promptsPath + stub.loaded = true + stub.exports = fake + require.cache[promptsPath] = stub + return () => { + if (saved === undefined) { + delete require.cache[promptsPath] + } else { + require.cache[promptsPath] = saved + } + } +} + +// cli-ux exposes `prompt` as a getter, so it has to be redefined rather than assigned. +function stubSendPassword(password) { + const saved = Object.getOwnPropertyDescriptor(sendCliUx, 'prompt') + const asked = [] + Object.defineProperty(sendCliUx, 'prompt', { + configurable: true, + get: () => async (message, options) => { + asked.push({message, options}) + return password + }, + }) + return {asked, restore: () => Object.defineProperty(sendCliUx, 'prompt', saved)} +} + +// Answer each prompt by name, and record the option objects so the tests can +// check the questions and their validators as well as the answers. +function answering(answers, seen) { + return async options => { + seen.push(options) + return Object.prototype.hasOwnProperty.call(answers, options.name) + ? {[options.name]: answers[options.name]} + : {} + } +} + +async function runSendInteractive(argv, {fakePrompts, fakePassword} = {}) { + const restorePrompts = stubSendPrompts(fakePrompts || (async () => ({}))) + const password = fakePassword === undefined ? null : stubSendPassword(fakePassword) + const savedStdout = process.stdout.isTTY + const savedStdin = process.stdin.isTTY + process.stdout.isTTY = true + process.stdin.isTTY = true + try { + const result = await runSendInProcess(argv) + return {...result, asked: password ? password.asked : []} + } finally { + process.stdout.isTTY = savedStdout + process.stdin.isTTY = savedStdin + if (password) { + password.restore() + } + restorePrompts() + } +} + +describe('send: signing and pushing what a node returned', () => { + let nodeWallet + + before(async function createNodeFixtures() { + this.timeout(180000) + await createOfflineWallet(SEND_NODE_WALLET, null) + ;[nodeWallet] = JSON.parse(fs.readFileSync(SEND_NODE_WALLET)) + fs.writeFileSync(SEND_NODE_NOT_JSON, 'this file is not a transaction') + // Encrypted with the current (authenticated) format, so a wrong password makes + // decryption throw rather than return nonsense. + const aes = require('../../src/utils/aes') // eslint-disable-line global-require + fs.writeFileSync( + SEND_NODE_ENC_WALLET, + JSON.stringify([ + { + encrypted: true, + address: aes.encrypt(SEND_NODE_PASSWORD, nodeWallet.address), + hexseed: aes.encrypt(SEND_NODE_PASSWORD, nodeWallet.hexseed), + }, + ]) + ) + }) + + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[sendCommandPath] + ;[SEND_NODE_WALLET, SEND_NODE_TX_FILE, SEND_NODE_NOT_JSON, SEND_NODE_ENC_WALLET].forEach(file => { + try { + fs.unlinkSync(file) + } catch (err) { + // fixture already gone; nothing to clean up + } + }) + }) + + beforeEach(() => { + sendNode = {} + sendCalls = [] + sendConnectAttempts = 0 + }) + + const sendArgs = (extra = []) => [ + '1.5', + '-r', RECIPIENT_A, + '-i', '0', + '-w', SEND_NODE_WALLET, + '-g', DEAD_NODE, + ...extra, + ] + + it('asks the node to build the transaction it was told to send', async function builds() { + this.timeout(120000) + const {code, out} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 0, out) + const build = sendCalls.find(c => c.name === 'TransferCoins') + assert.ok(build, `no TransferCoins call was made\n--- output ---\n${out}`) + assert.strictEqual(`Q${Buffer.from(build.request.addresses_to[0]).toString('hex')}`, RECIPIENT_A) + // 1.5 Quanta expressed in Shor, since -s was not given + assert.deepStrictEqual(build.request.amounts, ['1500000000']) + assert.strictEqual(build.request.fee, 0) + assert.strictEqual(build.request.message_data, undefined) + }) + + it('signs the transaction and reports the id the node gave back', async function signs() { + this.timeout(120000) + const {code, out} = await runSendInProcess(sendArgs(['-f', '100'])) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + // the sender named in the output is the wallet the funds actually leave + assert.ok(out.includes(`Sending from: ${nodeWallet.address}`), out) + const push = sendCalls.find(c => c.name === 'PushTransaction') + assert.ok(push, 'nothing was pushed') + assert.ok(push.request.transaction_signed.signature.length > 0, 'pushed without a signature') + assert.strictEqual(push.request.transaction_signed.fee, '100') + // The id printed is the one the stub derived from the signed transaction, so + // it can only match if the command signed what it asked the node to build. + const hash = nodeTransactionHash(push.request.transaction_signed).toString('hex') + assert.ok(out.includes(`transaction ID: ${hash}`), out) + }) + + it('blames the password, not the file, when an encrypted wallet will not open', async function wrongPw() { + this.timeout(120000) + // The wallet is fine; the password is not. The decryption error used to be caught by + // the same handler that reports an unreadable file, so it said "invalid wallet file". + const {code, out} = await runSendInProcess( + ['1', '-r', RECIPIENT_A, '-i', '0', '-w', SEND_NODE_ENC_WALLET, '-p', 'not-the-password', '-g', DEAD_NODE] + ) + assert.strictEqual(code, 1, out) + assert.ok(/Unable to open wallet file: invalid password/.test(out), out) + assert.ok(!/invalid wallet file/.test(out), `only one reason may be given\n--- actual ---\n${out}`) + }) + + it('reports a hexseed the XMSS library cannot use, rather than failing silently', async function badSeed() { + this.timeout(120000) + // The right length but not hex, so it clears the length check and only fails inside + // QRLLIB. That throw used to escape uncaught, exiting non-zero with nothing printed. + const {code, out} = await runSendInProcess( + ['1', '-r', RECIPIENT_A, '-i', '0', '-h', '020200cb68ca52ae4aff1d2ac10a2cc03f2325b95ab4610d2c6fd2af684aa1427766ac0b96b05942734d254fb9dba5fcb139HG', '-g', DEAD_NODE] + ) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to recreate XMSS wallet object/.test(out), out) + assert.strictEqual(sendCalls.length, 0, 'no node is contacted when the key cannot be rebuilt') + }) + + it('accepts an explicit fee of 0, the same value it uses when -f is omitted', async function zeroFee() { + this.timeout(120000) + // parseInt('0') is 0, which is falsy: testing the parsed fee for truthiness sent an + // explicit -f 0 down the "invalid" path, while omitting -f used 0 quite happily. + const {code, out} = await runSendInProcess(sendArgs(['-f', '0'])) + assert.strictEqual(code, 0, out) + assert.strictEqual(sendCalls.find(c => c.name === 'TransferCoins').request.fee, 0) + assert.strictEqual(sendCalls.find(c => c.name === 'PushTransaction').request.transaction_signed.fee, '0') + }) + + it('still refuses a negative fee', async function negativeFee() { + this.timeout(120000) + const {code, out} = await runSendInProcess(sendArgs(['-f', '-5'])) + assert.strictEqual(code, 1, out) + assert.ok(/Fee is invalid/.test(out), out) + assert.strictEqual(sendCalls.length, 0, 'nothing may be asked of a node for an unusable fee') + }) + + it('sends in shor when -s is given', async function shor() { + this.timeout(120000) + const {code} = await runSendInProcess( + ['12345', '-r', RECIPIENT_A, '-i', '0', '-w', SEND_NODE_WALLET, '-g', DEAD_NODE, '-s'] + ) + assert.strictEqual(code, 0) + assert.deepStrictEqual(sendCalls.find(c => c.name === 'TransferCoins').request.amounts, ['12345']) + }) + + it('attaches a message when one is given', async function withMessage() { + this.timeout(120000) + const {code, out} = await runSendInProcess(sendArgs(['-M', 'hello chain'])) + assert.strictEqual(code, 0, out) + const build = sendCalls.find(c => c.name === 'TransferCoins') + assert.strictEqual(Buffer.from(build.request.message_data).toString(), 'hello chain') + const push = sendCalls.find(c => c.name === 'PushTransaction') + assert.strictEqual( + Buffer.from(push.request.transaction_signed.transfer.message_data).toString(), + 'hello chain' + ) + }) + + it('sends every output of a multi-recipient JSON object', async function multi() { + this.timeout(120000) + const jsonObject = JSON.stringify({tx: [{to: RECIPIENT_A, shor: '10'}, {to: RECIPIENT_B, shor: '15'}]}) + const {code, out} = await runSendInProcess( + // the quantity argument is still required by the gate above, and ignored: + // the amounts come from the JSON object. + ['1', '-j', jsonObject, '-i', '0', '-w', SEND_NODE_WALLET, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 0, out) + const build = sendCalls.find(c => c.name === 'TransferCoins') + assert.deepStrictEqual(build.request.amounts, ['10', '15']) + assert.strictEqual(build.request.addresses_to.length, 2) + }) + + it('retries the connection until the node answers', async function retries() { + this.timeout(120000) + sendNode = {connectsOnAttempt: 3} + const {code} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 0) + assert.strictEqual(sendConnectAttempts, 3) + }) + + it('links to the mainnet explorer when sending on mainnet', async function mainnet() { + this.timeout(120000) + const {code, out} = await runSendInProcess( + ['1', '-r', RECIPIENT_A, '-i', '0', '-w', SEND_NODE_WALLET, '-m'] + ) + assert.strictEqual(code, 0, out) + assert.ok(/https:\/\/explorer\.theqrl\.org\/tx\/[0-9a-f]{64}/.test(out), out) + }) + + it('links to the testnet explorer when sending on testnet', async function testnet() { + this.timeout(120000) + const {code, out} = await runSendInProcess( + ['1', '-r', RECIPIENT_A, '-i', '0', '-w', SEND_NODE_WALLET, '-t'] + ) + assert.strictEqual(code, 0, out) + assert.ok(/https:\/\/testnet-explorer\.theqrl\.org\/tx\/[0-9a-f]{64}/.test(out), out) + }) + + it('refuses to sign a response that redirected the payment', async function tamperedRecipient() { + this.timeout(120000) + // The reason the binding check exists: a node that swaps the recipient gets + // a valid signature over its own transaction unless this refuses. + sendNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.transfer.addrs_to = [Buffer.from(RECIPIENT_B.substring(1), 'hex')] + return response + }, + } + const {code, out} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/transfer recipient at position 0/.test(out), out) + assert.ok(/Nothing was signed, no OTS key was used, and no funds have moved/.test(out), out) + assert.strictEqual(sendCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('refuses to sign a response that changed the amount', async function tamperedAmount() { + this.timeout(120000) + sendNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.transfer.amounts = ['999999999999'] + return response + }, + } + const {code, out} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/transfer amount at position 0/.test(out), out) + }) + + it('refuses to sign a response that changed the fee', async function tamperedFee() { + this.timeout(120000) + sendNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.fee = '100000000' + return response + }, + } + const {code, out} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/different fee/.test(out), out) + }) + + it('refuses to sign a response that changed the attached message', async function tamperedMessage() { + this.timeout(120000) + sendNode = { + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.transfer.message_data = Buffer.from('something else entirely') + return response + }, + } + const {code, out} = await runSendInProcess(sendArgs(['-M', 'hello chain'])) + assert.strictEqual(code, 1, out) + assert.ok(/different message/.test(out), out) + }) + + it('reports a node that rejects the push', async function pushRejected() { + this.timeout(120000) + sendNode = {pushResponse: {error_code: 'INVALID', error_description: 'OTS key reused'}} + const {code, out} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/OTS key reused/.test(out), out) + }) + + it('refuses a transaction id that is not the one it signed', async function hashMismatch() { + this.timeout(120000) + // A node that accepts the push but reports a different id has not submitted + // the transaction that was signed; saying "submitted" here would be a lie. + sendNode = {wrongHash: true} + const {code, out} = await runSendInProcess(sendArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/Node transaction hash 1111/.test(out), out) + }) + + describe('--loadfromfile', () => { + before(async function buildOfflineTx() { + this.timeout(120000) + // Sign offline first, exactly as a cold-wallet user would, so the file + // being loaded is one this command produced rather than a hand-written + // fixture that might not match the format it writes. + const {code, out} = await runSendInProcess( + ['2', '-r', RECIPIENT_A, '-i', '1', '-w', SEND_NODE_WALLET, '-T', SEND_NODE_TX_FILE] + ) + assert.strictEqual(code, 0, out) + assert.ok(fs.existsSync(SEND_NODE_TX_FILE), 'the offline transaction file was not written') + }) + + it('pushes a transaction signed offline without signing it again', async function pushesFile() { + this.timeout(120000) + const {code, out} = await runSendInProcess( + ['-F', SEND_NODE_TX_FILE, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 0, out) + assert.ok(/Successfully loaded from the file/.test(out), out) + // No transaction is built and nothing is re-signed: the file already + // carries the signature, and re-signing would spend a second OTS key. + assert.strictEqual(sendCalls.filter(c => c.name === 'TransferCoins').length, 0) + assert.ok(!/Transaction signed with OTS key/.test(out), out) + const push = sendCalls.find(c => c.name === 'PushTransaction') + const saved = JSON.parse(fs.readFileSync(SEND_NODE_TX_FILE)) + assert.strictEqual(push.request.transaction_signed.transaction_hash, saved.tx.transaction_hash) + }) + + it('pushes a transaction with an attached message signed offline', async function messageFile() { + this.timeout(120000) + // A message transaction is rebuilt from the file down a different branch, + // because message_data has to be carried through as well. + const messageFile = '/tmp/send-node-offline-message-tx.json' + try { + const saved = await runSendInProcess( + ['2', '-r', RECIPIENT_A, '-i', '2', '-M', 'signed offline', '-w', SEND_NODE_WALLET, '-T', messageFile] + ) + assert.strictEqual(saved.code, 0, saved.out) + + const {code, out} = await runSendInProcess( + ['-F', messageFile, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 0, out) + const push = sendCalls.find(c => c.name === 'PushTransaction') + assert.strictEqual( + Buffer.from(push.request.transaction_signed.transfer.message_data).toString(), + 'signed offline' + ) + } finally { + try { + fs.unlinkSync(messageFile) + } catch (err) { + // never created; nothing to clean up + } + } + }) + + it('rejects a transaction file that is not JSON', async function notJson() { + this.timeout(120000) + const {code, out} = await runSendInProcess( + ['-F', SEND_NODE_NOT_JSON, '-g', DEAD_NODE] + ) + assert.strictEqual(code, 1, out) + }) + }) + describe('at a terminal', () => { + const promptEncWallet = '/tmp/send-node-prompt-enc-wallet.json' + const WALLET_PASSWORD = 'prompted-password' + + before(() => { + // A wallet whose address and hexseed both decrypt with the same password, + // so the command gets past the address check and on to signing. + const aes = require('../../src/utils/aes') // eslint-disable-line global-require + fs.writeFileSync( + promptEncWallet, + JSON.stringify([ + { + encrypted: true, + address: aes.encrypt(WALLET_PASSWORD, nodeWallet.address), + hexseed: aes.encrypt(WALLET_PASSWORD, nodeWallet.hexseed), + }, + ]) + ) + }) + + after(() => { + try { + fs.unlinkSync(promptEncWallet) + } catch (err) { + // never created; nothing to clean up + } + }) + + it('asks for recipient, amount, OTS index and wallet when none are given', async function prompts() { + this.timeout(120000) + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering( + {recipient: RECIPIENT_A, quantity: 2, otsindex: 3, walletType: 'file', walletFile: SEND_NODE_WALLET}, + seen + ), + }) + assert.strictEqual(code, 0, out) + assert.deepStrictEqual( + seen.map(o => o.name), + ['recipient', 'quantity', 'otsindex', 'walletType', 'walletFile'] + ) + // the answers, not defaults, are what got sent and signed + const build = sendCalls.find(c => c.name === 'TransferCoins') + assert.strictEqual(`Q${Buffer.from(build.request.addresses_to[0]).toString('hex')}`, RECIPIENT_A) + assert.deepStrictEqual(build.request.amounts, ['2000000000']) + assert.ok(/Transaction signed with OTS key 3/.test(out), out) + }) + + it('validates what is typed at each prompt', async function validators() { + this.timeout(120000) + const seen = [] + await runSendInteractive([], { + fakePrompts: answering( + {recipient: RECIPIENT_A, quantity: 1, otsindex: 0, walletType: 'file', walletFile: SEND_NODE_WALLET}, + seen + ), + }) + const byName = Object.fromEntries(seen.map(o => [o.name, o])) + assert.strictEqual(byName.recipient.validate('not-an-address'), 'Invalid QRL address') + assert.strictEqual(byName.recipient.validate(RECIPIENT_A), true) + assert.strictEqual(byName.quantity.validate(0), 'Quantity must be positive') + assert.strictEqual(byName.quantity.validate(1), true) + assert.strictEqual(byName.otsindex.validate(-1), 'OTS index must be 0 or greater') + assert.strictEqual(byName.otsindex.validate(0), true) + assert.strictEqual(byName.walletFile.validate('/no/such/wallet.json'), 'File does not exist') + assert.strictEqual(byName.walletFile.validate(SEND_NODE_WALLET), true) + }) + + it('takes a hexseed typed at the prompt instead of a wallet file', async function seedPrompt() { + this.timeout(120000) + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering( + {recipient: RECIPIENT_A, quantity: 1, otsindex: 0, walletType: 'seed', hexseed: nodeWallet.hexseed}, + seen + ), + }) + assert.strictEqual(code, 0, out) + const seedPromptOptions = seen.find(o => o.name === 'hexseed') + assert.strictEqual(seedPromptOptions.type, 'password', 'a seed must not be echoed to the terminal') + assert.strictEqual(seedPromptOptions.validate(' '), 'Hexseed/Mnemonic is required') + assert.strictEqual(seedPromptOptions.validate(nodeWallet.hexseed), true) + }) + + it('exits when the recipient prompt is cancelled', async function cancelRecipient() { + this.timeout(120000) + const {code, out} = await runSendInteractive([], { + fakePrompts: async () => ({}), + }) + assert.strictEqual(code, 1, out) + assert.strictEqual(sendCalls.length, 0, 'no node is contacted without a recipient') + }) + + it('exits cleanly when the amount prompt is cancelled', async function cancelQuantity() { + this.timeout(120000) + // A cancelled numeric prompt answers with nothing, and the conversion to a + // string used to throw a TypeError before the cancellation was noticed. + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering({recipient: RECIPIENT_A}, seen), + }) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.ok(!/TypeError/.test(out), out) + assert.strictEqual(sendCalls.length, 0, 'no node is contacted without an amount') + }) + + it('exits cleanly when the OTS index prompt is cancelled', async function cancelOts() { + this.timeout(120000) + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering({recipient: RECIPIENT_A, quantity: 1}, seen), + }) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.ok(!/TypeError/.test(out), out) + }) + + it('treats a blank OTS index as a cancellation', async function blankOts() { + this.timeout(120000) + // The OTS validator reads `value >= 0`, and a blank answer coerces to 0, so it + // gets past validation as an empty string. Stopping on it is what keeps the + // command from signing with an index the user never chose. + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering({recipient: RECIPIENT_A, quantity: 1, otsindex: ''}, seen), + }) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.strictEqual(sendCalls.length, 0, 'nothing is signed with an index that was never chosen') + }) + + it('accepts OTS index zero, which is a valid answer', async function otsZero() { + this.timeout(120000) + // 0 is falsy, so it has to survive the cancellation check rather than be + // mistaken for no answer at all. + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering( + {recipient: RECIPIENT_A, quantity: 1, otsindex: 0, walletType: 'file', walletFile: SEND_NODE_WALLET}, + seen + ), + }) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + }) + + it('exits when neither wallet option is chosen', async function cancelWallet() { + this.timeout(120000) + const seen = [] + const {code, out} = await runSendInteractive([], { + fakePrompts: answering({recipient: RECIPIENT_A, quantity: 1, otsindex: 0}, seen), + }) + assert.strictEqual(code, 1, out) + assert.strictEqual(sendCalls.length, 0, 'no node is contacted without a key to sign with') + }) + + it('asks for the wallet password when --password is not given', async function passwordPrompt() { + this.timeout(120000) + const {code, out, asked} = await runSendInteractive( + ['1', '-r', RECIPIENT_A, '-i', '0', '-w', promptEncWallet, '-g', DEAD_NODE], + {fakePassword: WALLET_PASSWORD} + ) + assert.strictEqual(code, 0, out) + assert.strictEqual(asked.length, 1) + assert.ok(/Enter password for wallet file/.test(asked[0].message)) + assert.strictEqual(asked[0].options.type, 'hide', 'the password must not be echoed') + assert.ok(out.includes(`Sending from: ${nodeWallet.address}`), out) + }) + + it('refuses a wrong password typed at the wallet prompt', async function wrongPassword() { + this.timeout(120000) + const {code, out} = await runSendInteractive( + ['1', '-r', RECIPIENT_A, '-i', '0', '-w', promptEncWallet, '-g', DEAD_NODE], + {fakePassword: 'not-the-password'} + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(sendCalls.length, 0, 'nothing is signed or sent for a wallet that never opened') + }) + }) +}) diff --git a/test/commands/sign-tx-offline.test.js b/test/commands/sign-tx-offline.test.js new file mode 100644 index 0000000..3f202ae --- /dev/null +++ b/test/commands/sign-tx-offline.test.js @@ -0,0 +1,459 @@ +// /////////////////////////////////////////////////////////////////////////// +// sign-tx-offline tests +// +// sign-tx-offline is the one send-style command with every gRPC call commented +// out: it builds, signs and writes a transaction entirely locally. That makes +// the whole command — validation ladder, wallet/hexseed loading, the XMSS +// signing callback and the file write — reachable without a node, yet it was +// the least covered file in the repo. +// +// Nothing here touches the network and nothing is ever pushed to a node, so no +// OTS key is spent on chain. The wallets are throwaway height-4 trees built in +// before() and used nowhere else. +// /////////////////////////////////////////////////////////////////////////// + +const assert = require('assert') +const crypto = require('crypto') +const fs = require('fs') +const os = require('os') +const path = require('path') +const {spawn} = require('child_process') + +// Own scratch directory: several test files run in parallel and must not share +// fixture paths. +const TMP = path.join(os.tmpdir(), 'signtx-offline-tests') +const WALLET = path.join(TMP, 'wallet.json') +const ENC_WALLET = path.join(TMP, 'enc-wallet.json') +const LEGACY_WALLET = path.join(TMP, 'legacy-wallet.json') +const NOT_A_WALLET = path.join(TMP, 'not-a-wallet.json') +const RECIPIENTS = path.join(TMP, 'recipients.json') +const BAD_JSON_FILE = path.join(TMP, 'not-json.txt') +const EMPTY_TX_FILE = path.join(TMP, 'empty-tx.json') +const MISSING_FILE = path.join(TMP, 'does-not-exist.json') +const UNWRITABLE_OUT = path.join(path.sep, 'signtx-no-such-dir', 'out.json') + +const ENC_PASS = 'signtx-test-password' +const LEGACY_PASS = 'legacy-test-password' + +const TO_A = 'Q000300cc040d28c309c8e82d1397aa0d9b74666b492f77b485d327bf5496a725b7b8a3c024b9ee' +const TO_B = 'Q0103001d65d7e59aed5efbeae64246e0f3184d7c42411421eb385ba30f2c1c005a85ebc4419cfd' + +let wallet // the plaintext wallet as written by create-wallet + +// Run the CLI and capture what it said, rather than letting it write to the test output. +function run(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe']}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out})) + }) +} + +// Every refusal case must fail for the stated reason: asserting on the message +// stops a command that starts failing earlier (a missing flag, a bad fixture) +// from silently keeping the test green. +async function refuses(args, expected) { + const {code, out} = await run(args) + assert.notStrictEqual(code, 0, `expected a non-zero exit for: ${args.join(' ')}`) + assert.ok(expected.test(out), `expected output to match ${expected}\n--- actual ---\n${out}`) +} + +// Signing succeeds but the process still exits non-zero (see the exit-code test +// below), so success is judged on the messages and the file that was written. +async function signs(args) { + const {out} = await run(args) + assert.ok( + /Transaction written to /.test(out), + `expected a signed transaction for: ${args.join(' ')}\n--- actual ---\n${out}` + ) + return out +} + +// A wallet file in the pre-v2 format written by the old `aes256` package: +// unauthenticated AES-256-CTR, so a wrong password decrypts to garbage instead +// of throwing. That is the only way to reach the "invalid password" branch — +// v2 blobs fail authentication first and land on "invalid wallet file". +function legacyEncrypt(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(String(password)).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]) + return Buffer.concat([iv, ciphertext]).toString('base64') +} + +describe('sign-tx-offline', () => { + before(async () => { + fs.mkdirSync(TMP, {recursive: true}) + // Height 4 keeps key generation and signing fast; the tree height has no + // bearing on any path this file exercises. + await run(['create-wallet', '-h', '4', '-f', WALLET]) + await run(['create-wallet', '-h', '4', '-p', ENC_PASS, '-f', ENC_WALLET]) + ;[wallet] = JSON.parse(fs.readFileSync(WALLET)) + + fs.writeFileSync( + LEGACY_WALLET, + JSON.stringify([ + { + encrypted: true, + address: legacyEncrypt(LEGACY_PASS, wallet.address), + hexseed: legacyEncrypt(LEGACY_PASS, wallet.hexseed), + }, + ]) + ) + fs.writeFileSync(NOT_A_WALLET, JSON.stringify([{nothing: 'useful'}])) + fs.writeFileSync( + RECIPIENTS, + JSON.stringify({tx: [{to: TO_A, shor: '100'}, {to: TO_B, shor: '200'}]}) + ) + fs.writeFileSync(BAD_JSON_FILE, 'this is not json') + fs.writeFileSync(EMPTY_TX_FILE, JSON.stringify({tx: []})) + // Wiped, not just created: the fixture directory has a fixed name, so a file + // left by an earlier run would make "this was never written" assertions pass + // or fail on history rather than on what the command did. + fs.rmSync(path.join(TMP, 'out'), {recursive: true, force: true}) + fs.mkdirSync(path.join(TMP, 'out'), {recursive: true}) + }) + + // Signed transactions go in their own directory: an output path that collided + // with a fixture would quietly overwrite the wallet under test. + const out = name => path.join(TMP, 'out', name) + + describe('recipient selection', () => { + it('refuses to sign with no recipient at all', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET], + /Unable to send: no recipients/ + ) + }) + + it('refuses when a recipient and a JSON object are both given', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-r', TO_A, '-j', '{"tx":[]}'], + /use either recipient \(-r\)/ + ) + }) + + it('rejects -s alongside -j, where the amounts are already in Shor', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-s', '-j', '{"tx":[]}'], + /-s flag is redundant/ + ) + }) + + it('rejects -s alongside -R, where the amounts are already in Shor', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-s', '-R', RECIPIENTS], + /-s flag is redundant/ + ) + }) + + it('refuses to sign without a wallet or a hexseed', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-r', TO_A], + /no wallet json file or hexseed specified/ + ) + }) + + it('rejects an invalid recipient address', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-r', 'Qdeadbeef'], + /Unable to send: invalid recipient address/ + ) + }) + }) + + // The -j/-R output arrays are user-supplied and go straight into the signed + // payload, so each rejection in checkTxJSON is worth pinning individually. + describe('output JSON validation', () => { + const withJson = json => ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-j', json] + + it('rejects a -j value that is not JSON', async () => { + await refuses(withJson('not-json-at-all'), /invalid output data \(not-json-at-all\)/) + }) + + it('rejects a -j object with no tx array', async () => { + await refuses(withJson('{"foo":1}'), /array is undefined/) + }) + + it('rejects an empty tx array', async () => { + await refuses(withJson('{"tx":[]}'), /length of array is 0/) + }) + + it("rejects an output with no 'to' key", async () => { + await refuses(withJson('{"tx":[{"shor":"100"}]}'), /Output #0 does not have a 'to' key/) + }) + + it('rejects an output whose address is not a QRL address', async () => { + await refuses( + withJson('{"tx":[{"to":"Qdeadbeef","shor":"100"}]}'), + /Output #0 does not contain a valid QRL address/ + ) + }) + + it("rejects an output with no 'shor' key", async () => { + await refuses(withJson(`{"tx":[{"to":"${TO_A}"}]}`), /Output #0 does not have a 'shor' key/) + }) + + it('rejects a -R file that is not JSON', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-R', BAD_JSON_FILE], + /invalid output data \(this is not json/ + ) + }) + + it('rejects a -R file whose tx array is empty', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-R', EMPTY_TX_FILE], + /json file contains invalid output data \(No transactions found/ + ) + }) + + it('reports a missing -R file rather than signing', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', WALLET, '-R', MISSING_FILE], + /ENOENT/ + ) + }) + }) + + describe('key material', () => { + it('rejects a JSON file that is not a wallet', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', NOT_A_WALLET, '-r', TO_A], + /Unable to open wallet file: invalid wallet file/ + ) + }) + + it('rejects the wrong password for an encrypted wallet', async () => { + // The wallet is fine; the password is not, and the message says so. It used to + // report "invalid wallet file", because the decryption error was caught by the + // same handler that reports an unreadable file. + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-w', ENC_WALLET, '-p', 'not-the-password', '-r', TO_A], + /Unable to open wallet file: invalid password/ + ) + }) + + it('rejects a wallet file that is not JSON, rather than crashing', async () => { + // The read used to sit outside the try, so a file that is not JSON escaped as a + // raw SyntaxError instead of reaching the message below. + await refuses( + ['sign-tx-offline', '1', out('b.json'), '-i', '0', '-w', BAD_JSON_FILE, '-r', TO_A], + /Unable to open wallet file: invalid wallet file/ + ) + }) + + it('rejects a truncated hexseed', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-h', '000200aabbcc', '-r', TO_A], + /Hexseed invalid: too short/ + ) + }) + + it('rejects a mnemonic without 34 words', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-h', 'aback bunny unfair', '-r', TO_A], + /Mnemonic phrase invalid: too short/ + ) + }) + }) + + describe('ots index and fee', () => { + it('rejects a non-numeric OTS index', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', 'not-a-number', '-w', WALLET, '-r', TO_A], + /OTS key is invalid/ + ) + }) + + it('accepts an explicit fee of 0', async () => { + // Unlike the other spending commands this one defaults to 100, but 0 is still a + // fee the network accepts, and parseInt('0') being falsy is no reason to refuse it. + const file = out('zero-fee.json') + await signs(['sign-tx-offline', '1', file, '-i', '0', '-f', '0', '-w', WALLET, '-r', TO_A]) + assert.strictEqual(JSON.parse(fs.readFileSync(file)).fee, 0) + }) + + it('rejects a negative fee', async () => { + await refuses( + ['sign-tx-offline', '1', out('neg-fee.json'), '-i', '0', '-f', '-5', '-w', WALLET, '-r', TO_A], + /Fee is invalid/ + ) + }) + + it('reports a hexseed the XMSS library cannot use, rather than failing silently', async () => { + // The right length but not hex: it clears the length check and only fails inside + // QRLLIB, where the throw used to escape uncaught with nothing printed. + await refuses( + ['sign-tx-offline', '1', out('bad-seed.json'), '-i', '0', '-h', '020200cb68ca52ae4aff1d2ac10a2cc03f2325b95ab4610d2c6fd2af684aa1427766ac0b96b05942734d254fb9dba5fcb139HG', '-r', TO_A], + /Failed to recreate XMSS wallet object/ + ) + }) + + it('rejects a non-numeric fee', async () => { + await refuses( + ['sign-tx-offline', '1', out('a.json'), '-i', '0', '-f', 'free', '-w', WALLET, '-r', TO_A], + /Fee is invalid/ + ) + }) + + it('signs with a custom fee in Shor', async () => { + const file = out('fee.json') + const output = await signs([ + 'sign-tx-offline', '1000', file, '-i', '1', '-s', '-f', '250', '-w', WALLET, '-r', TO_A, + ]) + assert.ok(/Fee: 250 Shor/.test(output), output) + const tx = JSON.parse(fs.readFileSync(file)) + assert.strictEqual(tx.fee, 250) + // -s means the quantity is already Shor and must not be multiplied up. + assert.deepStrictEqual(tx.amounts, ['1000']) + }) + + // An empty --otsindex satisfies oclif's `required` check but is falsy, so it + // used to skip the validation above and sign with parseInt('') === NaN, + // writing a transaction whose OTS index was null. + it('rejects an empty OTS index rather than signing with NaN', async () => { + const file = out('empty-ots.json') + await refuses( + ['sign-tx-offline', '1', file, '-i', '', '-w', WALLET, '-r', TO_A], + /OTS key is invalid/ + ) + assert.strictEqual(fs.existsSync(file), false, 'nothing may be written for an unusable OTS index') + }) + }) + + describe('signing', () => { + it('signs from a plaintext wallet and converts Quanta to Shor', async () => { + const file = out('wallet.json') + const output = await signs(['sign-tx-offline', '1', file, '-i', '0', '-w', WALLET, '-r', TO_A]) + assert.ok(output.includes(`Sending from: ${wallet.address}`), output) + assert.ok(/Transaction signed with OTS key 0/.test(output), output) + assert.ok(output.includes(`Transaction written to ${file}`), output) + + const tx = JSON.parse(fs.readFileSync(file)) + assert.match(tx.hash, /^[0-9a-f]{64}$/) + assert.deepStrictEqual(tx.amounts, ['1000000000']) + assert.strictEqual(tx.fee, 100) + assert.strictEqual(tx.ots, 0) + assert.strictEqual(tx.addrs_to.length, 1) + // The public key travels with the transaction: a node cannot verify the + // signature without it, so a wrong one here would push an unusable tx. + assert.strictEqual(Buffer.from(tx.public_key.data).toString('hex'), wallet.pk) + }) + + it('signs a multi-output transaction from a -R file', async () => { + const file = out('multi.json') + const output = await signs([ + 'sign-tx-offline', '1', file, '-i', '1', '-w', WALLET, '-R', RECIPIENTS, + ]) + assert.ok(output.includes(`address to: ${TO_A}`), output) + assert.ok(output.includes(`address to: ${TO_B}`), output) + const tx = JSON.parse(fs.readFileSync(file)) + assert.deepStrictEqual(tx.amounts, ['100', '200']) + assert.strictEqual(tx.addrs_to.length, 2) + }) + + it('signs a multi-output transaction from a -j object', async () => { + const file = out('json-object.json') + await signs([ + 'sign-tx-offline', '1', file, '-i', '2', '-w', WALLET, + '-j', JSON.stringify({tx: [{to: TO_A, shor: '1'}, {to: TO_B, shor: '2'}]}), + ]) + assert.deepStrictEqual(JSON.parse(fs.readFileSync(file)).amounts, ['1', '2']) + }) + + it('signs from a hexseed with no wallet file', async () => { + const file = out('hexseed.json') + await signs(['sign-tx-offline', '1', file, '-i', '3', '-h', wallet.hexseed, '-r', TO_A]) + assert.match(JSON.parse(fs.readFileSync(file)).hash, /^[0-9a-f]{64}$/) + }) + + it('signs from a mnemonic', async () => { + const file = out('mnemonic.json') + await signs(['sign-tx-offline', '1', file, '-i', '4', '-h', wallet.mnemonic, '-r', TO_A]) + const tx = JSON.parse(fs.readFileSync(file)) + // Same key, reached by a different constructor: the public key must match + // the one the hexseed produces. + assert.strictEqual(Buffer.from(tx.public_key.data).toString('hex'), wallet.pk) + }) + + it('signs from an encrypted wallet given the password', async () => { + const file = out('encrypted.json') + const encAddress = JSON.parse(fs.readFileSync(ENC_WALLET))[0].address + const output = await signs([ + 'sign-tx-offline', '1', file, '-i', '0', '-w', ENC_WALLET, '-p', ENC_PASS, '-r', TO_A, + ]) + assert.ok(/Sending from: Q[0-9a-f]{78}/.test(output), output) + assert.ok(!output.includes(encAddress), 'the encrypted address must not be echoed verbatim') + assert.match(JSON.parse(fs.readFileSync(file)).hash, /^[0-9a-f]{64}$/) + }) + + // The command signs inside a QRLLIB callback that run() awaits, so the + // this.exit(0) at the end of it reaches oclif and the process exits cleanly. + // Before that, a successful signing reported failure to anything checking $?. + it('exits zero when the transaction is signed and written', async () => { + const file = out('exit-code.json') + const {code, out: output} = await run([ + 'sign-tx-offline', '1', file, '-i', '5', '-w', WALLET, '-r', TO_A, + ]) + assert.ok(output.includes(`Transaction written to ${file}`), output) + assert.strictEqual(code, 0, output) + }) + + it('fails cleanly when the output file cannot be written', async () => { + await refuses( + ['sign-tx-offline', '1', UNWRITABLE_OUT, '-i', '0', '-w', WALLET, '-r', TO_A], + /Writing transaction to file .* failed/ + ) + }) + }) + + // cli-ux's hidden prompt shells out to `read -s` and needs a TTY, so the + // no-password path cannot be driven through a spawned child. Run the command + // in-process instead with the prompt stubbed. + describe('password prompt', () => { + const {cli} = require('cli-ux') // eslint-disable-line global-require + const {SignTxOffline} = require('../../src/commands/sign-tx-offline') // eslint-disable-line global-require + + it('prompts for the password when none is given, and rejects a wrong one', async () => { + const promptDescriptor = Object.getOwnPropertyDescriptor(cli, 'prompt') + const write = process.stdout.write.bind(process.stdout) + let logged = '' + let prompted = false + Object.defineProperty(cli, 'prompt', { + configurable: true, + get: () => async () => { + prompted = true + return 'wrong-password' + }, + }) + process.stdout.write = chunk => { + logged += chunk + return true + } + let error + try { + await SignTxOffline.run([ + '1', path.join(TMP, 'prompted.json'), '-i', '0', '-w', LEGACY_WALLET, '-r', TO_A, + ]) + } catch (e) { + error = e + } finally { + process.stdout.write = write + Object.defineProperty(cli, 'prompt', promptDescriptor) + } + assert.ok(prompted, 'expected the command to prompt for a wallet password') + assert.ok(error, 'expected a non-zero exit') + assert.strictEqual(error.oclif.exit, 1) + // A legacy blob decrypts to garbage under the wrong password, so the + // address check is the only thing catching it. + assert.ok(/invalid password/.test(logged), logged) + }) + }) +}) diff --git a/test/commands/status.test.js b/test/commands/status.test.js index e2427af..2c23433 100644 --- a/test/commands/status.test.js +++ b/test/commands/status.test.js @@ -1,4 +1,25 @@ +// /////////////////////////////////////////////////////////////////////////// +// status command tests +// +// `status` is one of the two commands here that has to reach a node before it +// can do anything, so the cases split in two: +// +// * the original cases, which query a real public node. They are skipped when +// QRL_TEST_OFFLINE=true, the same switch test/hooks.js already uses, so the +// suite is runnable with no network at all. +// * offline cases, which point the command at a closed loopback port. Those +// exercise everything up to and including the connection-failure reporting +// without leaving the machine. +// +// Child processes get a throwaway config directory: `status` resolves its +// endpoint through the `conf` store, so a value in the developer's real config +// would otherwise change which node these tests talk to. +// /////////////////////////////////////////////////////////////////////////// + const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') const {spawn} = require('child_process') const processFlags = { @@ -6,7 +27,35 @@ const processFlags = { stdio: ['ignore', 'inherit', 'inherit'], } -describe('status #1', () => { +// The cases that need a live node. Skipped rather than deleted so they still +// run in the normal, networked CI job. +const describeOnline = process.env.QRL_TEST_OFFLINE === 'true' ? describe.skip : describe + +// A closed port on loopback: the connection is refused immediately and +// deterministically, and nothing leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +// kleur colours its output even into a pipe; strip the escapes before matching. +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +let tempHome +let childEnv + +function run(args) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'pipe', 'pipe'], env: childEnv}) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out: out.replace(ANSI, '')})) + }) +} + +describeOnline('status #1', () => { let exitCode const args = [ 'status', @@ -24,7 +73,7 @@ describe('status #1', () => { }) -describe('status #2', () => { +describeOnline('status #2', () => { let exitCode const args = [ 'status', @@ -42,7 +91,7 @@ describe('status #2', () => { }) }) -describe('status #3', () => { +describeOnline('status #3', () => { let exitCode const args = ['status', '-t'] before(done => { @@ -74,7 +123,7 @@ describe('status #3', () => { // }) -describe('status #5', () => { +describeOnline('status #5', () => { let exitCode const args = ['status', '-g', 'mainnet-3.automated.theqrl.org:19009'] before(done => { @@ -120,3 +169,295 @@ describe('status #7', () => { }) // need to inject false proto shasums to test lines 40, 41 and 49 + +// /////////////////////////////////////////////////////////////////////////// +// Offline behaviour: what the user is told when the node cannot be reached. +// +// The cases above only assert exit codes, so a `status` that failed for an +// entirely different reason -- a crash in argument handling, say -- would still +// have looked like a pass. These assert on the message. +// /////////////////////////////////////////////////////////////////////////// + +describe('status: when the node cannot be reached', () => { + before(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-status-test-')) + childEnv = { + ...process.env, + HOME: tempHome, + XDG_CONFIG_HOME: tempHome, + APPDATA: tempHome, + LOCALAPPDATA: tempHome, + } + }) + + after(() => { + fs.rmSync(tempHome, {recursive: true, force: true}) + }) + + it('names the endpoint it is about to query before querying it', async () => { + // The banner is the only thing that tells a user which host the CLI is + // about to speak plaintext gRPC to, so it has to name the custom endpoint + // rather than the network the flags nominally selected. + const {code, out} = await run(['status', '-g', DEAD_NODE]) + assert.notStrictEqual(code, 0) + assert.ok(out.includes(`Custom GRPC endpoint: [${DEAD_NODE}]`), out) + }) + + it('reports the connection failure and exits non-zero', async () => { + const {code, out} = await run(['status', '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node\. Check network connection & parameters/.test(out), out) + assert.ok(/ECONNREFUSED/.test(out), out) + // and it must not go on to print a status it never received + assert.ok(!/Block height/.test(out), out) + }) + + it('reports the connection failure without a spinner in --json mode', async () => { + // With --json there is no spinner to fail, so the error takes a different + // branch. It still has to say something: a silent non-zero exit here would + // be indistinguishable from a node that answered with nothing. + const {code, out} = await run(['status', '--json', '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node:/.test(out), out) + assert.ok(/ECONNREFUSED/.test(out), out) + // --json suppresses the human banner + assert.ok(!out.includes('Custom GRPC endpoint:'), out) + }) + + it('honours -j as well as --json', async () => { + const {code, out} = await run(['status', '-j', '-g', DEAD_NODE]) + assert.strictEqual(code, 1) + assert.ok(/Failed to connect to node:/.test(out), out) + }) + + it('takes the endpoint from the config store when no flag is given', async () => { + // The stored grpc-endpoint silently redirects every command. Proving it is + // honoured here also proves these tests are reading the throwaway store and + // not the developer's own. + await run(['config', 'set', 'grpc-endpoint', DEAD_NODE]) + try { + const {code, out} = await run(['status']) + assert.strictEqual(code, 1) + assert.ok(out.includes(`Custom GRPC endpoint: [${DEAD_NODE}]`), out) + assert.ok(/Failed to connect to node/.test(out), out) + } finally { + await run(['config', 'delete', 'grpc-endpoint']) + } + }) +}) + +// /////////////////////////////////////////////////////////////////////////// +// status: what the command does once a node has answered +// +// Everything above stops at the connection. This suite runs the command in +// *this* process against a stub gRPC client, so the retry loop and both +// renderings of a GetStats reply - the human report and --json - are covered +// without a node, a socket or a packet leaving the machine. +// +// src/functions/grpc is swapped in the require cache only for the moment it +// takes to require the command (the command captures Qrlnode at require time), +// then the real module is put straight back. +// /////////////////////////////////////////////////////////////////////////// + +// Behaviour the stub should show for the test currently running. Reset per test. +let statusNode = {} +let statusCalls = [] +let statusConnectAttempts = 0 + +class StatusFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + statusConnectAttempts += 1 + if (statusNode.connectThrows) { + throw new Error(statusNode.connectThrows) + } + const connectsOn = statusNode.connectsOnAttempt === undefined ? 1 : statusNode.connectsOnAttempt + if (connectsOn !== 0 && statusConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name) { + statusCalls.push(name) + if (statusNode.apiThrows) { + throw new Error(statusNode.apiThrows) + } + return statusNode.stats + } +} + +const statusGrpcPath = require.resolve('../../src/functions/grpc') +const statusCommandPath = require.resolve('../../src/commands/status') + +const statusRealGrpcEntry = require.cache[statusGrpcPath] +require.cache[statusGrpcPath] = { + id: statusGrpcPath, + filename: statusGrpcPath, + path: path.dirname(statusGrpcPath), + loaded: true, + children: [], + paths: [], + exports: StatusFakeQrlNode, +} +const {Status} = require('../../src/commands/status') + +if (statusRealGrpcEntry) { + require.cache[statusGrpcPath] = statusRealGrpcEntry +} else { + delete require.cache[statusGrpcPath] +} + +// A GetStats reply, shaped the way the node returns it: every numeric field +// arrives as a string. +const STATS = { + uptime_network: '864000', + epoch: 7, + coins_emitted: '65000000000000000', + coins_total_supply: '105000000', + block_last_reward: '5324567890', + node_info: { + network_id: 'the QRL testnet', + version: '4.0.2 python', + state: 'SYNCED', + num_connections: '17', + num_known_peers: '42', + uptime: '172800', + block_height: '2764412', + }, +} + +// Run the command here, against the stub, capturing everything it prints. The +// human report goes through this.log, --json through console.log, and the ora +// spinners through stderr, so all three streams are captured. +async function runStatusOffline(argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = (chunk) => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await Status.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + return {code, out: chunks.join('').replace(ANSI, '')} +} + +describe('status: reporting a node reply', () => { + after(() => { + // The cached command module holds the stubbed Qrlnode; drop it so anything + // requiring it later in the same process gets the real client back. + delete require.cache[statusCommandPath] + }) + + beforeEach(() => { + statusNode = {stats: STATS} + statusCalls = [] + statusConnectAttempts = 0 + }) + + it('asks the node for its stats once it is connected', async () => { + const {code} = await runStatusOffline(['-g', DEAD_NODE]) + assert.strictEqual(code, 0) + assert.deepStrictEqual(statusCalls, ['GetStats']) + }) + + it('retries the connection until the node answers', async () => { + statusNode = {stats: STATS, connectsOnAttempt: 3} + const {code, out} = await runStatusOffline(['-g', DEAD_NODE]) + assert.strictEqual(code, 0, out) + assert.strictEqual(statusConnectAttempts, 3) + assert.ok(/retry connection attempt: 0/.test(out), out) + assert.ok(/retry connection attempt: 1/.test(out), out) + }) + + it('retries quietly in --json mode, where there is no spinner to update', async () => { + statusNode = {stats: STATS, connectsOnAttempt: 3} + const {code, out} = await runStatusOffline(['-g', DEAD_NODE, '--json']) + assert.strictEqual(code, 0, out) + assert.strictEqual(statusConnectAttempts, 3) + assert.ok(!/retry connection attempt/.test(out), out) + assert.strictEqual(JSON.parse(out).node.state, 'SYNCED') + }) + + describe('the human report', () => { + it('converts the network figures the node reports into what it claims to print', async () => { + const {code, out} = await runStatusOffline(['-g', DEAD_NODE]) + assert.strictEqual(code, 0, out) + assert.ok(out.includes('Network id the QRL testnet'), out) + // 864000 seconds of network uptime is 10 days, and the report says "days" + assert.ok(/Network uptime 10 days/.test(out), out) + assert.ok(/Epoch 7/.test(out), out) + // shor -> quanta, so the emitted supply is not printed off by 10^9 + assert.ok(/Coins emitted 65000000/.test(out), out) + assert.ok(/Total coin supply 105000000/.test(out), out) + assert.ok(/Last block reward 5\.32456789/.test(out), out) + }) + + it('prints the node figures under their own heading', async () => { + const {out} = await runStatusOffline(['-g', DEAD_NODE]) + assert.ok(/Node status:/.test(out), out) + assert.ok(/Version 4\.0\.2 python/.test(out), out) + assert.ok(/State SYNCED/.test(out), out) + assert.ok(/Connections 17/.test(out), out) + assert.ok(/Known peers 42/.test(out), out) + // 172800 seconds is 2 days + assert.ok(/Node uptime 2 days/.test(out), out) + assert.ok(/Block height 2764412/.test(out), out) + }) + }) + + describe('--json', () => { + it('prints one JSON object with the same figures and no banner', async () => { + const {code, out} = await runStatusOffline(['-g', DEAD_NODE, '--json']) + assert.strictEqual(code, 0, out) + const parsed = JSON.parse(out) + assert.deepStrictEqual(parsed.network, { + id: 'the QRL testnet', + uptime_days: 10, + epoch: 7, + coins_emitted: 65000000, + coins_total_supply: '105000000', + last_block_reward: 5.32456789, + }) + assert.deepStrictEqual(parsed.node, { + version: '4.0.2 python', + state: 'SYNCED', + connections: '17', + known_peers: '42', + uptime_days: 2, + block_height: '2764412', + }) + }) + + it('prints no spinner text alongside the JSON', async () => { + // Anything else on stdout would stop the output being parseable by the + // scripts --json exists for. + const {out} = await runStatusOffline(['-g', DEAD_NODE, '-j']) + assert.ok(!/Network status:/.test(out), out) + assert.ok(!/Custom GRPC endpoint/.test(out), out) + }) + }) + + it('surfaces a GetStats failure rather than reporting a blank status', async () => { + statusNode = {apiThrows: 'stream removed'} + const {code, out} = await runStatusOffline(['-g', DEAD_NODE]) + assert.strictEqual(code, 1, out) + assert.ok(!/Block height/.test(out), out) + }) +}) diff --git a/test/commands/token.test.js b/test/commands/token.test.js index 3309c9c..f47ef08 100644 --- a/test/commands/token.test.js +++ b/test/commands/token.test.js @@ -12,19 +12,43 @@ // /////////////////////////////////////////////////////////////////////////// const assert = require('assert') -const {spawn} = require('child_process') +const {spawn, execFileSync} = require('child_process') +const crypto = require('crypto') +const fs = require('fs') +const os = require('os') const path = require('path') -const setup = require('../test_setup') - // A closed port on loopback: the CLI resolves it, fails to connect, and exits. // Deterministic, and it never leaves the machine. const DEAD_NODE = '127.0.0.1:1' const VALID_ADDRESS = 'Q000300cc040d28c309c8e82d1397aa0d9b74666b492f77b485d327bf5496a725b7b8a3c024b9ee' +const SECOND_ADDRESS = 'Q000200ecffb27f3d7b11ccd048eb559277d64bb52bfda998341e66a9f11b2d07f6b2ee4f62c408' const VALID_TOKEN_HASH = '9d3f463b300012292eac668768f2969125ae540b1cdef7c99f6fea448e736af8' const NOT_A_WALLET = path.join(__dirname, '..', 'test-wallet', 'does-not-exist.json') +// Fixtures live in their own temp directory rather than test/test-wallet, because the +// shared hooks delete everything in that directory and other suites run in parallel. +const FIXTURES = path.join(os.tmpdir(), 'qrl-cli-token-fixtures') +const PLAIN_WALLET = path.join(FIXTURES, 'token-wallet.json') +const ENC_WALLET = path.join(FIXTURES, 'token-wallet-enc.json') +const LEGACY_WALLET = path.join(FIXTURES, 'token-wallet-legacy.json') +const MALFORMED_WALLET = path.join(FIXTURES, 'token-wallet-malformed.json') +const UNKNOWN_WALLET = path.join(FIXTURES, 'token-wallet-unknown.json') +const WALLET_PASSWORD = 'testpassword' +const WRONG_PASSWORD = 'not-the-password' + +// A 102-character seed that passes the length check but is not hex, so QRLLIB rejects it. +// This is the only way to reach the "failed to rebuild the XMSS object" arm without a node. +const UNPARSEABLE_HEXSEED = 'z'.repeat(102) + +// Enter is CR, not LF: prompts puts the terminal in raw mode, where LF is not a submit key. +const CR = '\r' +// Down arrow, as the terminal delivers it. Used to move onto the second sender choice. +const DOWN_ARROW = `${String.fromCharCode(27)}[B` +// Backspace, for clearing a rejected answer that the next keystrokes cannot simply fix. +const BACKSPACE = String.fromCharCode(127) + // Run the CLI and capture what it said, rather than letting it write to the test output. function run(args) { return new Promise(resolve => { @@ -36,6 +60,10 @@ function run(args) { child.stderr.on('data', d => { out += d.toString() }) + // Surface a failed spawn as output rather than an empty capture: under heavy parallel + // load fork can fail, and without this the case fails as "output did not match" with + // nothing to show, which reads like a CLI regression instead of a busy machine. + child.on('error', err => resolve({code: -1, out: `${out}\nspawn failed: ${err.message}`})) child.on('close', code => resolve({code, out})) }) } @@ -51,6 +79,172 @@ async function refuses(args, expected) { ) } +// /////////////////////////////////////////////////////////////////////////// +// Wallet fixtures +// +// Built here rather than borrowed from test_setup, so this file passes on its own: the +// shared wallets only exist when the whole suite runs with test/hooks.js required. +// /////////////////////////////////////////////////////////////////////////// + +// Reproduces the format written by the retired `aes256` package: key = sha256(password), +// AES-256-CTR, base64(iv || ciphertext). It is unauthenticated, so a wrong password +// decrypts to garbage rather than throwing, which is the only way to reach the commands' +// "invalid password" arm. Wallets in the current v2/GCM format fail closed instead. +function legacyEncrypt(password, plaintext) { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(password).digest() + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv) + const body = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]) + return Buffer.concat([iv, body]).toString('base64') +} + +let walletPromise = null +let plainWallet = null + +function createWallet(args) { + return new Promise((resolve, reject) => { + const child = spawn('./bin/run', args, {stdio: ['ignore', 'ignore', 'pipe']}) + let err = '' + child.stderr.on('data', d => { + err += d.toString() + }) + child.on('close', code => { + if (code === 0) { + resolve() + } else { + reject(new Error(`create-wallet exited ${code}: ${err}`)) + } + }) + }) +} + +async function buildFixtures() { + fs.mkdirSync(FIXTURES, {recursive: true}) + // Height 6 keeps wallet generation to about a second; no test ever spends an OTS key. + await createWallet(['create-wallet', '-h', '6', '-f', PLAIN_WALLET]) + await createWallet(['create-wallet', '-h', '6', '-f', ENC_WALLET, '-p', WALLET_PASSWORD]) + const [firstEntry] = JSON.parse(fs.readFileSync(PLAIN_WALLET)) + plainWallet = firstEntry + + fs.writeFileSync( + LEGACY_WALLET, + JSON.stringify([ + { + encrypted: true, + address: legacyEncrypt(WALLET_PASSWORD, plainWallet.address), + hexseed: legacyEncrypt(WALLET_PASSWORD, plainWallet.hexseed), + mnemonic: legacyEncrypt(WALLET_PASSWORD, plainWallet.mnemonic), + }, + ]) + ) + // Not JSON at all: readFileSync succeeds and JSON.parse is what throws. + fs.writeFileSync(MALFORMED_WALLET, 'this is not a wallet') + // Parses, but `encrypted` is neither true nor false, so neither branch claims the file. + fs.writeFileSync(UNKNOWN_WALLET, JSON.stringify([{encrypted: 'maybe', address: 'Qdeadbeef'}])) +} + +function fixtures() { + if (walletPromise === null) { + walletPromise = buildFixtures() + } + return walletPromise +} + +function useFixtures() { + before(function makeWallets() { + this.timeout(120000) + return fixtures() + }) +} + +// /////////////////////////////////////////////////////////////////////////// +// Interactive (pty) harness +// +// Both commands prompt for anything not supplied as a flag, but only when stdin and +// stdout are TTYs; through a pipe that whole half of each command is unreachable, which +// is why it was the largest untested region. util-linux `script` lends the child a pty +// without adding a native dependency. Where it is missing these cases skip rather than +// fail, and the flag-driven cases above still stand. +// /////////////////////////////////////////////////////////////////////////// + +const hasPty = (() => { + try { + const version = execFileSync('script', ['--version'], {stdio: ['ignore', 'pipe', 'ignore']}) + return /util-linux/.test(version.toString()) + } catch (error) { + return false + } +})() + +const shellQuote = arg => `'${String(arg).replace(/'/g, "'\\''")}'` + +// Drives one prompt at a time: wait for the prompt's text, then send its answer. Answers +// are never written ahead of the prompt that consumes them, because the tty hands the +// whole buffered line to the first reader. A step may send several keystrokes, written +// separately: an escape sequence must not be glued to the key after it, and cli-ux's +// password prompt compares each chunk against CR whole, so a password and its Enter have +// to arrive as two writes. +function runInteractive(args, steps) { + return new Promise(resolve => { + const command = ['./bin/run'].concat(args).map(shellQuote).join(' ') + const child = spawn('script', ['-q', '-e', '-c', command, '/dev/null'], { + stdio: ['pipe', 'pipe', 'pipe'], + }) + const remaining = steps.slice() + let all = '' + let window = '' + let sending = false + + const pump = () => { + if (sending || remaining.length === 0 || !remaining[0].expect.test(window)) { + return + } + const step = remaining.shift() + // Fresh window per step, so the next prompt matches against new output only. Prompts + // redraw their whole line on every keystroke, so a shared buffer would self-match. + window = '' + sending = true + const keys = Array.isArray(step.send) ? step.send.slice() : [step.send] + const sendNext = () => { + if (keys.length === 0) { + sending = false + if (step.eof) { + child.stdin.end() + } + pump() + return + } + child.stdin.write(keys.shift()) + setTimeout(sendNext, 120) + } + sendNext() + } + + const onData = d => { + all += d.toString() + window += d.toString() + pump() + } + child.stdout.on('data', onData) + child.stderr.on('data', onData) + child.on('error', err => resolve({code: -1, out: `${all}\nspawn failed: ${err.message}`, unmet: remaining.length})) + child.on('close', code => resolve({code, out: all, unmet: remaining.length})) + }) +} + +async function interactively(args, steps, expected) { + const {out, unmet} = await runInteractive(args, steps) + assert.strictEqual(unmet, 0, `${unmet} prompt(s) never appeared\n--- actual ---\n${out}`) + assert.ok( + expected.test(out), + `expected output to match ${expected}\n--- actual ---\n${out}` + ) +} + +// Wallet generation plus a pty round trip per keystroke; generous, but these never hang +// because the CLI always terminates at the closed port or at a cancelled prompt. +const SLOW = 120000 + describe('token:create validation', () => { it('exits non-zero with no arguments', async () => { await refuses(['token:create'], /Missing required flag: --symbol/) @@ -192,13 +386,456 @@ describe('token:transfer validation', () => { }) }) +// /////////////////////////////////////////////////////////////////////////// +// Wallet and seed handling +// +// Everything below rebuilds a real XMSS object from a real wallet and then stops at the +// closed port. Reaching the connection attempt is the point: it proves the wallet was +// opened, decrypted where needed, and turned into keys, which is the work that has to be +// right before a signature is ever produced. +// /////////////////////////////////////////////////////////////////////////// + +function createArgs(extra) { + return [ + 'token:create', '--symbol', 'TST', '--name', 'Test Token', + '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', '--grpc', DEAD_NODE, + ].concat(extra) +} + +function transferArgs(extra) { + return [ + 'token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--otsindex', '0', '--grpc', DEAD_NODE, + ].concat(extra) +} + +// The wallet was read and the keys rebuilt; only the node was missing. +const REACHED_NODE = /Failed to connect to node/ + +describe('token:create wallet and seed handling', () => { + useFixtures() + + it('loads a plaintext wallet and reaches the node with a custom fee and decimals', async () => { + // Two holders and non-default fee/decimals in one pass: the multi-holder loop and the + // flag-supplied fee are among the values the transaction is bound to before signing. + await refuses( + createArgs([ + '--holder', `${SECOND_ADDRESS}:250`, '--decimals', '3', '--fee', '500', + '--wallet', PLAIN_WALLET, + ]), + REACHED_NODE + ) + }).timeout(SLOW) + + it('opens an encrypted wallet with the right password', async () => { + await refuses( + createArgs(['--wallet', ENC_WALLET, '--password', WALLET_PASSWORD]), + REACHED_NODE + ) + }).timeout(SLOW) + + it('refuses an encrypted wallet with the wrong password', async () => { + // The current wallet format is authenticated, so a wrong password fails the auth tag + // and never yields a plausible-looking address to sign with. What it reports is that + // the password is wrong - it used to blame the file, which was fine all along. + await refuses( + createArgs(['--wallet', ENC_WALLET, '--password', WRONG_PASSWORD]), + /invalid password/i + ) + }).timeout(SLOW) + + it('still opens a legacy-format encrypted wallet', async () => { + await refuses( + createArgs(['--wallet', LEGACY_WALLET, '--password', WALLET_PASSWORD]), + REACHED_NODE + ) + }).timeout(SLOW) + + it('reports a bad password on a legacy wallet as a bad password', async () => { + // Legacy blobs are unauthenticated: a wrong password yields garbage instead of an + // error, so the address check is the only thing between the user and signing with a + // key that is not theirs. + await refuses( + createArgs(['--wallet', LEGACY_WALLET, '--password', WRONG_PASSWORD]), + /invalid password/i + ) + }).timeout(SLOW) + + it('rejects a wallet file that is not JSON', async () => { + await refuses(createArgs(['--wallet', MALFORMED_WALLET]), /invalid wallet file/i) + }).timeout(SLOW) + + it('rejects a wallet whose encrypted flag is neither true nor false', async () => { + await refuses(createArgs(['--wallet', UNKNOWN_WALLET]), /invalid wallet file/i) + }).timeout(SLOW) + + it('accepts a full-length hexseed', async () => { + await refuses(createArgs(['--hexseed', plainWallet.hexseed]), REACHED_NODE) + }).timeout(SLOW) + + it('accepts a 34-word mnemonic', async () => { + await refuses(createArgs(['--hexseed', plainWallet.mnemonic]), REACHED_NODE) + }).timeout(SLOW) + + it('reports a hexseed that is the right length but not a seed', async () => { + await refuses( + createArgs(['--hexseed', UNPARSEABLE_HEXSEED]), + /Failed to recreate XMSS wallet object/ + ) + }).timeout(SLOW) +}) + +describe('token:transfer wallet and seed handling', () => { + useFixtures() + + it('loads a plaintext wallet and reaches the node with a custom fee', async () => { + await refuses(transferArgs(['--fee', '500', '--wallet', PLAIN_WALLET]), REACHED_NODE) + }).timeout(SLOW) + + it('opens an encrypted wallet with the right password', async () => { + await refuses( + transferArgs(['--wallet', ENC_WALLET, '--password', WALLET_PASSWORD]), + REACHED_NODE + ) + }).timeout(SLOW) + + it('refuses an encrypted wallet with the wrong password', async () => { + await refuses( + transferArgs(['--wallet', ENC_WALLET, '--password', WRONG_PASSWORD]), + /invalid password/i + ) + }).timeout(SLOW) + + it('still opens a legacy-format encrypted wallet', async () => { + await refuses( + transferArgs(['--wallet', LEGACY_WALLET, '--password', WALLET_PASSWORD]), + REACHED_NODE + ) + }).timeout(SLOW) + + it('reports a bad password on a legacy wallet as a bad password', async () => { + await refuses( + transferArgs(['--wallet', LEGACY_WALLET, '--password', WRONG_PASSWORD]), + /invalid password/i + ) + }).timeout(SLOW) + + it('rejects a wallet file that is not JSON', async () => { + await refuses(transferArgs(['--wallet', MALFORMED_WALLET]), /invalid wallet file/i) + }).timeout(SLOW) + + it('rejects a wallet whose encrypted flag is neither true nor false', async () => { + await refuses(transferArgs(['--wallet', UNKNOWN_WALLET]), /invalid wallet file/i) + }).timeout(SLOW) + + it('accepts a full-length hexseed', async () => { + await refuses(transferArgs(['--hexseed', plainWallet.hexseed]), REACHED_NODE) + }).timeout(SLOW) + + it('accepts a 34-word mnemonic', async () => { + await refuses(transferArgs(['--hexseed', plainWallet.mnemonic]), REACHED_NODE) + }).timeout(SLOW) + + it('reports a hexseed that is the right length but not a seed', async () => { + await refuses( + transferArgs(['--hexseed', UNPARSEABLE_HEXSEED]), + /Failed to recreate XMSS wallet object/ + ) + }).timeout(SLOW) + + it('rejects a mnemonic with the wrong word count', async () => { + await refuses( + transferArgs(['--hexseed', 'absorb filter chalk']), + /Mnemonic phrase invalid|too short/i + ) + }).timeout(SLOW) +}) + +describe('token:create interactive prompts', () => { + useFixtures() + + before(function needsPty() { + if (!hasPty) { + this.skip() + } + }) + + it('asks again for answers that fail validation, then reaches the node', async () => { + // The whole ladder with nothing on the command line, and an answer rejected at each + // prompt whose validator can be satisfied on the retry: symbol, name, the holder + // amount, then the holder loop's blank-address exit, OTS index and the wallet file. + await interactively( + ['token:create', '--grpc', DEAD_NODE], + [ + {expect: /Enter Token Symbol/, send: CR}, + {expect: /Symbol must be between 1 and 10 characters/, send: `TST${CR}`}, + {expect: /Enter Token Name/, send: CR}, + {expect: /Name must be between 1 and 30 characters/, send: `Test Token${CR}`}, + {expect: /Enter Decimal Precision/, send: CR}, + {expect: /Holder QRL Address/, send: `${VALID_ADDRESS}${CR}`}, + {expect: /Amount for/, send: CR}, + {expect: /Amount must be positive/, send: `100${CR}`}, + {expect: /Holder QRL Address/, send: CR}, + {expect: /Enter OTS key index/, send: `0${CR}`}, + {expect: /specify the sender wallet/, send: CR}, + {expect: /path to wallet file/, send: `${PLAIN_WALLET}${CR}`}, + ], + REACHED_NODE + ) + }).timeout(SLOW) + + it('refuses a decimal precision outside 0-9', async () => { + // 12 is rejected, one backspace makes it 1, and the command carries on with the + // corrected answer rather than the one that was typed first. + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', + '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', + '--wallet', PLAIN_WALLET, '--grpc', DEAD_NODE], + [ + {expect: /Enter Decimal Precision/, send: `12${CR}`}, + {expect: /Decimals must be between 0 and 9/, send: [BACKSPACE, CR]}, + ], + REACHED_NODE + ) + }).timeout(SLOW) + + it('refuses an invalid address inside the holder loop', async () => { + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--grpc', DEAD_NODE], + [ + {expect: /Holder QRL Address/, send: `Qdeadbeef${CR}`}, + {expect: /Invalid QRL address/, send: [], eof: true}, + ], + /Invalid QRL address/ + ) + }).timeout(SLOW) + + it('refuses a negative OTS index', async () => { + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--holder', `${VALID_ADDRESS}:100`, '--grpc', DEAD_NODE], + [ + {expect: /Enter OTS key index/, send: `-1${CR}`}, + {expect: /OTS index must be 0 or greater/, send: [], eof: true}, + ], + /OTS index must be 0 or greater/ + ) + }).timeout(SLOW) + + it('treats a blank OTS index as a cancellation', async () => { + // The validator reads `value >= 0`, and an empty answer coerces to 0, so a blank + // submission gets past it. Stopping on the empty string afterwards is what keeps the + // command from signing with an OTS index the user never chose. + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--holder', `${VALID_ADDRESS}:100`, '--grpc', DEAD_NODE], + [{expect: /Enter OTS key index/, send: CR}], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('refuses a wallet path that does not exist', async () => { + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', '--grpc', DEAD_NODE], + [ + {expect: /specify the sender wallet/, send: CR}, + {expect: /path to wallet file/, send: `/no${CR}`}, + {expect: /File does not exist/, send: [], eof: true}, + ], + /File does not exist/ + ) + }).timeout(SLOW) + + it('takes a hexseed from the second sender choice', async () => { + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', '--grpc', DEAD_NODE], + [ + {expect: /specify the sender wallet/, send: [DOWN_ARROW, CR]}, + {expect: /Hexseed or Mnemonic/, send: CR}, + {expect: /Hexseed\/Mnemonic is required/, send: `${plainWallet.hexseed}${CR}`}, + ], + REACHED_NODE + ) + }).timeout(SLOW) + + it('asks for the wallet password when it is not on the command line', async () => { + // Typing the password beats passing --password, which leaves it in the shell history; + // the prompted route has to open the same file just as well. + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', + '--wallet', ENC_WALLET, '--grpc', DEAD_NODE], + [{expect: /password for wallet file/, send: [WALLET_PASSWORD, CR]}], + REACHED_NODE + ) + }).timeout(SLOW) + + it('treats an abandoned symbol prompt as a cancellation', async () => { + // Closing stdin aborts the prompt, the same as a user pressing ctrl-c: no answer comes + // back, so the command has to stop rather than carry an undefined symbol forward. + await interactively( + ['token:create', '--grpc', DEAD_NODE], + [{expect: /Enter Token Symbol/, send: [], eof: true}], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('treats an abandoned name prompt as a cancellation', async () => { + await interactively( + ['token:create', '--grpc', DEAD_NODE], + [ + {expect: /Enter Token Symbol/, send: `TST${CR}`}, + {expect: /Enter Token Name/, send: [], eof: true}, + ], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('treats an abandoned sender question as a cancellation', async () => { + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--decimals', '9', + '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', '--grpc', DEAD_NODE], + [{expect: /specify the sender wallet/, send: [], eof: true}], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('will not finish with an empty holder list', async () => { + // Finishing the holder loop without adding anyone has to fail: a token whose entire + // supply is allocated to nobody is not something to spend an OTS key on. + await interactively( + ['token:create', '--symbol', 'TST', '--name', 'Test Token', '--grpc', DEAD_NODE], + [ + {expect: /Enter Decimal Precision/, send: CR}, + {expect: /Holder QRL Address/, send: CR}, + ], + /at least one initial balance holder/ + ) + }).timeout(SLOW) +}) + +describe('token:transfer interactive prompts', () => { + useFixtures() + + before(function needsPty() { + if (!hasPty) { + this.skip() + } + }) + + it('asks again for answers that fail validation, then reaches the node', async () => { + await interactively( + ['token:transfer', '--grpc', DEAD_NODE], + [ + {expect: /Enter Token Creation TxID/, send: CR}, + {expect: /Token TxID must be a 64-character hex string/, send: `${VALID_TOKEN_HASH}${CR}`}, + {expect: /Enter Recipient QRL Address/, send: `Qdeadbeef${CR}`}, + {expect: /Invalid QRL address/, send: [BACKSPACE.repeat(9), `${VALID_ADDRESS}${CR}`]}, + {expect: /Enter Amount of Tokens/, send: CR}, + {expect: /Amount must be positive/, send: `5${CR}`}, + {expect: /Enter OTS key index/, send: `0${CR}`}, + {expect: /specify the sender wallet/, send: CR}, + {expect: /path to wallet file/, send: `${PLAIN_WALLET}${CR}`}, + ], + REACHED_NODE + ) + }).timeout(SLOW) + + it('refuses a negative OTS index', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--grpc', DEAD_NODE], + [ + {expect: /Enter OTS key index/, send: `-1${CR}`}, + {expect: /OTS index must be 0 or greater/, send: [], eof: true}, + ], + /OTS index must be 0 or greater/ + ) + }).timeout(SLOW) + + it('treats a blank OTS index as a cancellation', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--grpc', DEAD_NODE], + [{expect: /Enter OTS key index/, send: CR}], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('refuses a wallet path that does not exist', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--otsindex', '0', '--grpc', DEAD_NODE], + [ + {expect: /specify the sender wallet/, send: CR}, + {expect: /path to wallet file/, send: `/no${CR}`}, + {expect: /File does not exist/, send: [], eof: true}, + ], + /File does not exist/ + ) + }).timeout(SLOW) + + it('takes a mnemonic from the second sender choice', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--otsindex', '0', '--grpc', DEAD_NODE], + [ + {expect: /specify the sender wallet/, send: [DOWN_ARROW, CR]}, + {expect: /Hexseed or Mnemonic/, send: CR}, + {expect: /Hexseed\/Mnemonic is required/, send: `${plainWallet.mnemonic}${CR}`}, + ], + REACHED_NODE + ) + }).timeout(SLOW) + + it('asks for the wallet password when it is not on the command line', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--otsindex', '0', '--wallet', ENC_WALLET, '--grpc', DEAD_NODE], + [{expect: /password for wallet file/, send: [WALLET_PASSWORD, CR]}], + REACHED_NODE + ) + }).timeout(SLOW) + + it('treats an abandoned token hash prompt as a cancellation', async () => { + await interactively( + ['token:transfer', '--grpc', DEAD_NODE], + [{expect: /Enter Token Creation TxID/, send: [], eof: true}], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('treats an abandoned recipient prompt as a cancellation', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--grpc', DEAD_NODE], + [{expect: /Enter Recipient QRL Address/, send: [], eof: true}], + /Operation cancelled/ + ) + }).timeout(SLOW) + + it('treats an abandoned sender question as a cancellation', async () => { + await interactively( + ['token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, + '--amount', '5', '--otsindex', '0', '--grpc', DEAD_NODE], + [{expect: /specify the sender wallet/, send: [], eof: true}], + /Operation cancelled/ + ) + }).timeout(SLOW) +}) + + describe('token commands against an unreachable node', () => { + useFixtures() + // The last gate before the network. These reach the gRPC connection attempt, which is what // exercises wallet loading, XMSS reconstruction and endpoint selection end to end. it('token:transfer reports a connection failure rather than hanging', async () => { const {code, out} = await run([ 'token:transfer', '--tokenHash', VALID_TOKEN_HASH, '--recipient', VALID_ADDRESS, - '--amount', '5', '--otsindex', '0', '--wallet', setup.walletFile, '--grpc', DEAD_NODE, + '--amount', '5', '--otsindex', '0', '--wallet', PLAIN_WALLET, '--grpc', DEAD_NODE, ]) assert.notStrictEqual(code, 0) assert.ok( @@ -211,7 +848,7 @@ describe('token commands against an unreachable node', () => { const {code, out} = await run([ 'token:create', '--symbol', 'TST', '--name', 'Test Token', '--holder', `${VALID_ADDRESS}:100`, '--otsindex', '0', - '--wallet', setup.walletFile, '--grpc', DEAD_NODE, + '--wallet', PLAIN_WALLET, '--grpc', DEAD_NODE, ]) assert.notStrictEqual(code, 0) assert.ok( @@ -219,4 +856,619 @@ describe('token commands against an unreachable node', () => { `expected a connection failure, got:\n${out}` ) }).timeout(120000) + +}) + +// /////////////////////////////////////////////////////////////////////////// +// token:create / token:transfer — what happens once a node has answered +// +// Everything above stops at a validation gate or at a closed loopback port, so +// the half of each command that runs after the node replies — the request it +// builds, the binding check that decides whether to sign at all, the signature +// itself and the push — was unreachable. +// +// These cases run both commands in *this* process against a stub gRPC client. +// src/functions/grpc is swapped in the require cache for the moment it takes to +// require each command (they capture Qrlnode at require time), then the real +// module is put straight back. There is no server and no socket. +// +// The signing is real: a throwaway height-6 wallet built in before() is used to +// produce genuine XMSS signatures. Nothing is ever pushed to a network, so no +// on-chain OTS key is spent — the wallet exists only for this file. +// /////////////////////////////////////////////////////////////////////////// + +// kleur colours by environment variable rather than by isTTY, so captured +// output still carries escape sequences. Strip them before matching. +const TOKEN_ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +// Behaviour the stub should show for the test currently running. Reset per test. +let tokenNode = {} +let tokenCalls = [] +let tokenConnectAttempts = 0 + +class TokenFakeQrlNode { + constructor(endpoint) { + this.endpoint = endpoint + this.connection = false + } + + // eslint-disable-next-line class-methods-use-this + async connect() { + tokenConnectAttempts += 1 + if (tokenNode.connectThrows) { + throw new Error(tokenNode.connectThrows) + } + const connectsOn = tokenNode.connectsOnAttempt === undefined ? 1 : tokenNode.connectsOnAttempt + if (connectsOn !== 0 && tokenConnectAttempts >= connectsOn) { + this.connection = true + } + return this.connection + } + + // eslint-disable-next-line class-methods-use-this + async api(name, request) { + tokenCalls.push({name, request}) + if (name === 'PushTransaction') { + if (tokenNode.pushThrows) { + throw new Error(tokenNode.pushThrows) + } + return tokenNode.pushResponse || {tx_hash: Buffer.from('cd'.repeat(32), 'hex')} + } + if (tokenNode.buildThrows) { + throw new Error(tokenNode.buildThrows) + } + const built = tokenNode.build(request) + return tokenNode.tamper ? tokenNode.tamper(built) : built + } +} + +// Require both commands against the stub, then restore the real client. +const tokenGrpcPath = require.resolve('../../src/functions/grpc') +const tokenCreatePath = require.resolve('../../src/commands/token/create') +const tokenTransferPath = require.resolve('../../src/commands/token/transfer') + +const tokenRealGrpcEntry = require.cache[tokenGrpcPath] +require.cache[tokenGrpcPath] = { + id: tokenGrpcPath, + filename: tokenGrpcPath, + path: path.dirname(tokenGrpcPath), + loaded: true, + children: [], + paths: [], + exports: TokenFakeQrlNode, +} +const TokenCreate = require('../../src/commands/token/create') +const TokenTransfer = require('../../src/commands/token/transfer') + +if (tokenRealGrpcEntry) { + require.cache[tokenGrpcPath] = tokenRealGrpcEntry +} else { + delete require.cache[tokenGrpcPath] +} + +// Run the command here, against the stub, capturing everything it prints (this.log +// and console.log go to stdout, the ora spinners go to stderr). run() awaits the whole +// signing and pushing sequence, so an exit inside it arrives as a thrown ExitError. +async function runTokenInProcess(Cmd, argv) { + const chunks = [] + const realStdout = process.stdout.write + const realStderr = process.stderr.write + const capture = chunk => { + chunks.push(chunk.toString()) + return true + } + process.stdout.write = capture + process.stderr.write = capture + let code = 0 + try { + await Cmd.run(argv) + } catch (error) { + code = error.oclif && error.oclif.exit !== undefined ? error.oclif.exit : 1 + } finally { + process.stdout.write = realStdout + process.stderr.write = realStderr + } + // kleur colours by environment variable rather than by isTTY, so the captured output + // still carries escape sequences here. Strip them so the assertions read as the text a + // person would see. + return {code, out: chunks.join('').replace(TOKEN_ANSI, '')} +} + +const TOKEN_SYMBOL = 'TST' +const TOKEN_NAME = 'Test Token' +const TOKEN_PUSH_HASH = 'cd'.repeat(32) + +// What an honest node returns for GetTokenTxn: the request echoed back in the +// shape the proto loader produces, with every uint64 as a string. +const buildTokenCreateResponse = request => ({ + extended_transaction_unsigned: { + tx: { + master_addr: Buffer.from(request.master_addr), + fee: String(request.fee), + token: { + symbol: Buffer.from(request.symbol), + name: Buffer.from(request.name), + owner: Buffer.from(request.owner), + decimals: String(request.decimals), + initial_balances: request.initial_balances.map(item => ({ + address: Buffer.from(item.address), + amount: String(item.amount), + })), + }, + }, + }, +}) + +// The same for GetTransferTokenTxn. Note the token hash comes back as the raw +// 32 bytes even though it was sent as an ASCII hex string. +const buildTokenTransferResponse = request => ({ + extended_transaction_unsigned: { + tx: { + master_addr: Buffer.from(request.master_addr), + fee: String(request.fee), + transfer_token: { + token_txhash: Buffer.from(request.token_txhash.toString(), 'hex'), + addrs_to: request.addresses_to.map(item => Buffer.from(item)), + amounts: request.amounts.map(String), + }, + }, + }, +}) + +// The prompts only run when stdin and stdout are terminals. Fake the terminal and +// stand in for `prompts`, which both commands require lazily inside run(). +function stubTokenPrompts(fake) { + const promptsPath = require.resolve('prompts') + const saved = require.cache[promptsPath] + const Module = require('module') // eslint-disable-line global-require + const stub = new Module(promptsPath, null) + stub.filename = promptsPath + stub.loaded = true + stub.exports = fake + require.cache[promptsPath] = stub + return () => { + if (saved === undefined) { + delete require.cache[promptsPath] + } else { + require.cache[promptsPath] = saved + } + } +} + +// Answer each prompt by name; anything not listed is left unanswered, which is +// what `prompts` returns when the person cancels. +function tokenAnswering(answers) { + return async options => + (Object.prototype.hasOwnProperty.call(answers, options.name) ? {[options.name]: answers[options.name]} : {}) +} + +async function runTokenInteractive(Cmd, argv, answers) { + const restorePrompts = stubTokenPrompts(tokenAnswering(answers)) + const savedStdout = process.stdout.isTTY + const savedStdin = process.stdin.isTTY + process.stdout.isTTY = true + process.stdin.isTTY = true + try { + return await runTokenInProcess(Cmd, argv) + } finally { + process.stdout.isTTY = savedStdout + process.stdin.isTTY = savedStdin + restorePrompts() + } +} + +describe('token: signing and pushing what a node returned', () => { + useFixtures() + + // Last describe in the file, so this is where the shared wallet fixtures go. + after(() => { + // These cached modules hold the stubbed Qrlnode; drop them so anything + // requiring them later in the same process gets the real client back. + delete require.cache[tokenCreatePath] + delete require.cache[tokenTransferPath] + fs.rmSync(FIXTURES, {recursive: true, force: true}) + }) + + beforeEach(() => { + tokenNode = {build: buildTokenCreateResponse} + tokenCalls = [] + tokenConnectAttempts = 0 + }) + + const createArgs = (extra = []) => [ + '-s', TOKEN_SYMBOL, + '-n', TOKEN_NAME, + '-d', '9', + '-H', `${VALID_ADDRESS}:1000`, + '-i', '0', + '-w', PLAIN_WALLET, + '-g', DEAD_NODE, + ...extra, + ] + + const transferArgs = (extra = []) => [ + '-x', VALID_TOKEN_HASH, + '-r', VALID_ADDRESS, + '-a', '5', + '-i', '1', + '-w', PLAIN_WALLET, + '-g', DEAD_NODE, + ...extra, + ] + + describe('token:create', () => { + it('sends the symbol, name, owner and holders it was given', async function sends() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 0, out) + const build = tokenCalls.find(c => c.name === 'GetTokenTxn') + assert.ok(build, `no GetTokenTxn call was made\n--- output ---\n${out}`) + assert.strictEqual(build.request.symbol.toString(), TOKEN_SYMBOL) + assert.strictEqual(build.request.name.toString(), TOKEN_NAME) + assert.strictEqual(build.request.decimals, 9) + // the owner is the wallet's own address, not one of the holders + assert.strictEqual(`Q${build.request.owner.toString('hex')}`, plainWallet.address) + assert.deepStrictEqual(build.request.initial_balances.map(b => b.amount), [1000]) + assert.strictEqual(`Q${build.request.initial_balances[0].address.toString('hex')}`, VALID_ADDRESS) + // default fee, since none was given + assert.strictEqual(build.request.fee, 100) + }) + + it('signs, pushes, and reports the values it committed to', async function reports() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess(TokenCreate, createArgs(['-f', '250'])) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + assert.ok(out.includes(`Token Symbol: ${TOKEN_SYMBOL}`), out) + assert.ok(out.includes(`Token Name: ${TOKEN_NAME}`), out) + assert.ok(out.includes('Decimals: 9'), out) + assert.ok(out.includes('Fee (Shor): 250'), out) + assert.ok(out.includes(`${VALID_ADDRESS}: 1000`), out) + assert.ok(out.includes(`Token Creation TxID: ${TOKEN_PUSH_HASH}`), out) + + // the pushed transaction is the one that was signed, and it carries a signature + const push = tokenCalls.find(c => c.name === 'PushTransaction') + assert.ok(push, 'nothing was pushed') + assert.ok(push.request.transaction_signed.signature.length > 0, 'pushed without a signature') + assert.strictEqual(push.request.transaction_signed.fee, '250') + }) + + it('prints the same values as JSON when asked', async function json() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess(TokenCreate, createArgs(['-j'])) + assert.strictEqual(code, 0, out) + const parsed = JSON.parse(out.slice(out.indexOf('{'))) + assert.strictEqual(parsed.symbol, TOKEN_SYMBOL) + assert.strictEqual(parsed.name, TOKEN_NAME) + assert.strictEqual(parsed.decimals, '9') + assert.strictEqual(parsed.fee, '100') + assert.deepStrictEqual(parsed.initialBalances, [{address: VALID_ADDRESS, amount: '1000'}]) + assert.strictEqual(parsed.txhash, TOKEN_PUSH_HASH) + assert.strictEqual(parsed.status, 'SUBMITTED') + }) + + it('retries the connection until the node answers', async function retries() { + this.timeout(SLOW) + tokenNode = {build: buildTokenCreateResponse, connectsOnAttempt: 3} + const {code} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 0) + assert.strictEqual(tokenConnectAttempts, 3) + }) + + it('refuses to sign a response that changed the token owner', async function tamperedOwner() { + this.timeout(SLOW) + // The owner holds the minting rights. A node that rewrites it and gets a + // signature has taken the token; nothing may be signed here. + tokenNode = { + build: buildTokenCreateResponse, + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.token.owner = Buffer.from(SECOND_ADDRESS.substring(1), 'hex') + return response + }, + } + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/token owner/.test(out), out) + assert.ok(/Nothing was signed, no OTS key was used, and no token has been created/.test(out), out) + assert.strictEqual(tokenCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('refuses to sign a response that changed a holder amount', async function tamperedAmount() { + this.timeout(SLOW) + tokenNode = { + build: buildTokenCreateResponse, + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.token.initial_balances[0].amount = '999999' + return response + }, + } + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/initial balances amount at position 0/.test(out), out) + assert.strictEqual(tokenCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('reports a signing failure that is not a binding failure', async function badOts() { + this.timeout(SLOW) + // OTS index 999 does not exist in a height-6 tree, so the response binds + // cleanly and xmss.sign() is what fails. + const args = [ + '-s', TOKEN_SYMBOL, + '-n', TOKEN_NAME, + '-H', `${VALID_ADDRESS}:1000`, + '-i', '999', + '-w', PLAIN_WALLET, + '-g', DEAD_NODE, + ] + const {code, out} = await runTokenInProcess(TokenCreate, args) + assert.strictEqual(code, 1, out) + assert.strictEqual(tokenCalls.filter(c => c.name === 'PushTransaction').length, 0) + }) + + it('reports a node that refuses to build the transaction', async function buildRefused() { + this.timeout(SLOW) + tokenNode = {buildThrows: 'invalid token symbol'} + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/invalid token symbol/.test(out), out) + }) + + it('reports a node that rejects the push', async function pushRejected() { + this.timeout(SLOW) + tokenNode = { + build: buildTokenCreateResponse, + pushResponse: {error_code: 'INVALID', error_description: 'token symbol already exists'}, + } + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/token symbol already exists/.test(out), out) + }) + + it('reports a gRPC failure during the push', async function pushFailed() { + this.timeout(SLOW) + tokenNode = {build: buildTokenCreateResponse, pushThrows: 'stream removed'} + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/stream removed/.test(out), out) + }) + + it('reports a connection that fails outright', async function connectFailed() { + this.timeout(SLOW) + tokenNode = {connectThrows: 'no route to host'} + const {code, out} = await runTokenInProcess(TokenCreate, createArgs()) + assert.strictEqual(code, 1, out) + assert.strictEqual(tokenCalls.length, 0, 'nothing may be asked of a node that never connected') + }) + + it('reports a hexseed the XMSS library cannot use', async function badSeed() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess( + TokenCreate, + createArgs(['-h', UNPARSEABLE_HEXSEED]) + ) + assert.strictEqual(code, 1, out) + assert.strictEqual(tokenCalls.length, 0, 'no node is contacted when the key cannot be rebuilt') + }) + }) + + describe('token:transfer', () => { + beforeEach(() => { + tokenNode = {build: buildTokenTransferResponse} + }) + + it('sends the token hash as ASCII hex and the recipient as bytes', async function sends() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 0, out) + const build = tokenCalls.find(c => c.name === 'GetTransferTokenTxn') + assert.ok(build, `no GetTransferTokenTxn call was made\n--- output ---\n${out}`) + // the node decodes this field as text, so it must not be sent as raw bytes + assert.strictEqual(build.request.token_txhash.toString(), VALID_TOKEN_HASH) + assert.strictEqual(`Q${build.request.addresses_to[0].toString('hex')}`, VALID_ADDRESS) + assert.deepStrictEqual(build.request.amounts, [5]) + assert.strictEqual(build.request.fee, 100) + }) + + it('signs, pushes, and reports the values it committed to', async function reports() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess( + TokenTransfer, + transferArgs(['-f', '300']) + ) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 1/.test(out), out) + assert.ok(out.includes(`Recipient Address: ${VALID_ADDRESS}`), out) + assert.ok(out.includes(`Token TxID (Hash): ${VALID_TOKEN_HASH}`), out) + assert.ok(out.includes('Amount Transferred: 5'), out) + assert.ok(out.includes('Fee (Shor): 300'), out) + assert.ok(out.includes(`Transaction TxID: ${TOKEN_PUSH_HASH}`), out) + + const push = tokenCalls.find(c => c.name === 'PushTransaction') + assert.ok(push.request.transaction_signed.signature.length > 0, 'pushed without a signature') + }) + + it('prints the same values as JSON when asked', async function json() { + this.timeout(SLOW) + const {code, out} = await runTokenInProcess( + TokenTransfer, + transferArgs(['-j']) + ) + assert.strictEqual(code, 0, out) + const parsed = JSON.parse(out.slice(out.indexOf('{'))) + assert.deepStrictEqual(parsed, { + recipient: VALID_ADDRESS, + tokenHash: VALID_TOKEN_HASH, + amount: '5', + fee: '100', + txhash: TOKEN_PUSH_HASH, + status: 'SUBMITTED', + }) + }) + + it('refuses to sign a response that redirected the transfer', async function tamperedRecipient() { + this.timeout(SLOW) + // The whole point of the binding check: a node that swaps the recipient + // gets a valid signature over its own transaction unless this refuses. + tokenNode = { + build: buildTokenTransferResponse, + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.transfer_token.addrs_to = [Buffer.from(SECOND_ADDRESS.substring(1), 'hex')] + return response + }, + } + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/transfer recipient at position 0/.test(out), out) + assert.ok(/Nothing was signed, no OTS key was used, and no funds have moved/.test(out), out) + assert.strictEqual(tokenCalls.filter(c => c.name === 'PushTransaction').length, 0, 'nothing may be pushed') + }) + + it('refuses to sign a response that changed the token being sent', async function tamperedHash() { + this.timeout(SLOW) + tokenNode = { + build: buildTokenTransferResponse, + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.transfer_token.token_txhash = Buffer.alloc(32, 0xAB) + return response + }, + } + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/token hash/.test(out), out) + }) + + it('refuses to sign a response that changed the fee', async function tamperedFee() { + this.timeout(SLOW) + tokenNode = { + build: buildTokenTransferResponse, + tamper: response => { + const {tx} = response.extended_transaction_unsigned + tx.fee = '100000000' + return response + }, + } + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/fee/.test(out), out) + }) + + it('reports a signing failure that is not a binding failure', async function badOts() { + this.timeout(SLOW) + // OTS index 999 does not exist in a height-6 tree, so the response binds + // cleanly and xmss.sign() is what fails. + const args = [ + '-x', VALID_TOKEN_HASH, + '-r', VALID_ADDRESS, + '-a', '5', + '-i', '999', + '-w', PLAIN_WALLET, + '-g', DEAD_NODE, + ] + const {code, out} = await runTokenInProcess(TokenTransfer, args) + assert.strictEqual(code, 1, out) + assert.ok(/Failed to sign transaction/.test(out), out) + assert.strictEqual(tokenCalls.filter(c => c.name === 'PushTransaction').length, 0) + }) + + it('reports a node that refuses to build the transaction', async function buildRefused() { + this.timeout(SLOW) + tokenNode = {buildThrows: 'unknown token'} + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/unknown token/.test(out), out) + }) + + it('reports a node that rejects the push', async function pushRejected() { + this.timeout(SLOW) + tokenNode = { + build: buildTokenTransferResponse, + pushResponse: {error_code: 'INVALID', error_description: 'insufficient token balance'}, + } + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + assert.ok(/insufficient token balance/.test(out), out) + }) + + it('reports a gRPC failure during the push', async function pushFailed() { + this.timeout(SLOW) + tokenNode = {build: buildTokenTransferResponse, pushThrows: 'stream removed'} + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + }) + + it('retries the connection until the node answers', async function retries() { + this.timeout(SLOW) + tokenNode = {build: buildTokenTransferResponse, connectsOnAttempt: 3} + const {code} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 0) + assert.strictEqual(tokenConnectAttempts, 3) + }) + + it('reports a connection that fails outright', async function connectFailed() { + this.timeout(SLOW) + tokenNode = {connectThrows: 'no route to host'} + const {code, out} = await runTokenInProcess(TokenTransfer, transferArgs()) + assert.strictEqual(code, 1, out) + assert.strictEqual(tokenCalls.length, 0) + }) + }) + describe('cancelled prompts', () => { + // A cancelled numeric prompt answers with nothing at all, and the conversion to + // a string used to throw a TypeError one line ahead of the check meant to catch + // it. Each of these leaves exactly one prompt unanswered. + it('exits cleanly when token:create loses the OTS index prompt', async function createOts() { + this.timeout(SLOW) + const {code, out} = await runTokenInteractive( + TokenCreate, + ['-s', TOKEN_SYMBOL, '-n', TOKEN_NAME, '-d', '9', '-H', `${VALID_ADDRESS}:1000`, '-g', DEAD_NODE], + {} + ) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.ok(!/TypeError/.test(out), out) + assert.strictEqual(tokenCalls.length, 0, 'no node is contacted without an OTS index') + }) + + it('exits cleanly when token:transfer loses the amount prompt', async function transferAmount() { + this.timeout(SLOW) + const {code, out} = await runTokenInteractive( + TokenTransfer, + ['-x', VALID_TOKEN_HASH, '-r', VALID_ADDRESS, '-g', DEAD_NODE], + {} + ) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.ok(!/TypeError/.test(out), out) + }) + + it('exits cleanly when token:transfer loses the OTS index prompt', async function transferOts() { + this.timeout(SLOW) + const {code, out} = await runTokenInteractive( + TokenTransfer, + ['-x', VALID_TOKEN_HASH, '-r', VALID_ADDRESS, '-a', '5', '-g', DEAD_NODE], + {} + ) + assert.strictEqual(code, 1, out) + assert.ok(/Operation cancelled/.test(out), out) + assert.ok(!/TypeError/.test(out), out) + }) + + it('accepts OTS index zero from token:create, which is a valid answer', async function otsZero() { + this.timeout(SLOW) + // 0 is falsy, so it has to survive the cancellation check rather than be + // mistaken for no answer at all. + const {code, out} = await runTokenInteractive( + TokenCreate, + ['-s', TOKEN_SYMBOL, '-n', TOKEN_NAME, '-d', '9', '-H', `${VALID_ADDRESS}:1000`, + '-w', PLAIN_WALLET, '-g', DEAD_NODE], + {otsindex: 0} + ) + assert.strictEqual(code, 0, out) + assert.ok(/Transaction signed with OTS key 0/.test(out), out) + }) + }) }) diff --git a/test/commands/validate.test.js b/test/commands/validate.test.js index 5ed2012..db1b4ef 100644 --- a/test/commands/validate.test.js +++ b/test/commands/validate.test.js @@ -95,3 +95,213 @@ describe('validate', () => { assert.notStrictEqual(exitCode, 0) }) }) + +// /////////////////////////////////////////////////////////////////////////// +// Everything below is additional coverage for the paths the cases above never +// reach: the two output modes, the stdin/pipe entry points, and the +// interactive prompt. +// +// `validate` is pure local address validation -- no node is involved -- so all +// of this is offline by construction. +// /////////////////////////////////////////////////////////////////////////// + +const Module = require('module') + +const ROOT = require('path').join(__dirname, '..', '..') + +// kleur colours its output even into a pipe; strip the escapes before matching. +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +const VALID = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f3' +const BAD_CHECKSUM = 'Q010500bc576efa69fd6cbc854f2224f149f0b0a4d18fcb30c1feab64781245f4f27a61874227f4' + +// Run the CLI as a child process, optionally feeding it stdin, and capture what +// it said rather than letting it write into the test output. +function run(args, stdin) { + return new Promise(resolve => { + const child = spawn('./bin/run', args, { + stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }) + let out = '' + child.stdout.on('data', d => { + out += d.toString() + }) + child.stderr.on('data', d => { + out += d.toString() + }) + child.on('close', code => resolve({code, out: out.replace(ANSI, '')})) + if (stdin !== undefined) { + child.stdin.end(stdin) + } + }) +} + +// The interactive branch only runs when both streams are TTYs, which a spawned +// child with pipes can never be. Drive the command in-process instead, with the +// TTY flags forced on and `prompts` replaced by a stub. +function stubPrompts(fake) { + const promptsPath = require.resolve('prompts') + const saved = require.cache[promptsPath] + const stub = new Module(promptsPath, null) + stub.filename = promptsPath + stub.loaded = true + stub.exports = fake + require.cache[promptsPath] = stub + return () => { + if (saved === undefined) { + delete require.cache[promptsPath] + } else { + require.cache[promptsPath] = saved + } + } +} + +// oclif's help layer measures the terminal at require time, so both it and the +// command have to be loaded before the TTY flags are forced on. +let oclifConfig +let Validate + +before(async () => { + // eslint-disable-next-line global-require + oclifConfig = await require('@oclif/config').load(ROOT) + // eslint-disable-next-line global-require + Validate = require('../../src/commands/validate') +}) + +async function runInteractive(argv, fakePrompts) { + const restorePrompts = stubPrompts(fakePrompts) + const savedStdout = process.stdout.isTTY + const savedStdin = process.stdin.isTTY + const savedWrite = process.stdout.write + const savedWindowSize = process.stdout.getWindowSize + let out = '' + process.stdout.isTTY = true + process.stdin.isTTY = true + if (!process.stdout.getWindowSize) { + process.stdout.getWindowSize = () => [80, 24] + } + process.stdout.write = chunk => { + out += chunk.toString() + return true + } + try { + const cmd = new Validate(argv, oclifConfig) + await cmd.run() + return {code: 0, out: out.replace(ANSI, '')} + } catch (error) { + const code = error.oclif ? error.oclif.exit : 1 + return {code, out: out.replace(ANSI, '')} + } finally { + process.stdout.write = savedWrite + process.stdout.getWindowSize = savedWindowSize + process.stdout.isTTY = savedStdout + process.stdin.isTTY = savedStdin + restorePrompts() + } +} + +describe('validate: what it actually reports', () => { + it('names every check it performed for a valid address', async () => { + // The exit code alone cannot tell a user which property failed, so the + // per-check lines are the real output of this command. + const {code, out} = await run(['validate', VALID]) + assert.strictEqual(code, 0) + assert.ok(/Length: 79 characters/.test(out), out) + assert.ok(/Starts with Q/.test(out), out) + assert.ok(/Signature scheme: XMSS/.test(out), out) + assert.ok(/Hash: SHAKE-128/.test(out), out) + assert.ok(/Tree height: /.test(out), out) + assert.ok(/Checksum/.test(out), out) + assert.ok(/VALID/.test(out), out) + }) + + it('marks an address that fails only its checksum as INVALID', async () => { + // This address differs from the valid one by a single trailing character: + // everything except the checksum passes, which is exactly the case a user + // hits after a typo, and exactly the case that must not be reported valid. + const {code, out} = await run(['validate', BAD_CHECKSUM]) + assert.notStrictEqual(code, 0) + assert.ok(/INVALID/.test(out), out) + }) + + it('prints nothing but the exit code in quiet mode', async () => { + const {code, out} = await run(['validate', '-q', VALID]) + assert.strictEqual(code, 0) + assert.strictEqual(out.trim(), '') + }) + + it('emits parseable JSON with --json, and json wins over quiet', async () => { + const {code, out} = await run(['validate', '--json', '--quiet', VALID]) + assert.strictEqual(code, 0) + const parsed = JSON.parse(out) + assert.strictEqual(parsed.result, true) + assert.strictEqual(parsed.checksum.result, true) + assert.strictEqual(parsed.startQ.result, true) + }) + + it('still exits non-zero for an invalid address in JSON mode', async () => { + const {code, out} = await run(['validate', '-j', BAD_CHECKSUM]) + assert.notStrictEqual(code, 0) + const parsed = JSON.parse(out) + assert.strictEqual(parsed.result, false) + assert.strictEqual(parsed.checksum.result, false) + }) +}) + +describe('validate: reading the address from stdin', () => { + it('reads a piped address when no argument is given', async () => { + const {code, out} = await run(['validate'], `${VALID}\n`) + assert.strictEqual(code, 0) + assert.ok(/VALID/.test(out), out) + }) + + it('reads a piped address when the argument is "-"', async () => { + const {code, out} = await run(['validate', '-'], `${VALID}\n`) + assert.strictEqual(code, 0) + assert.ok(/VALID/.test(out), out) + }) + + it('rejects a piped address that is invalid', async () => { + const {code, out} = await run(['validate', '-'], `${BAD_CHECKSUM}\n`) + assert.notStrictEqual(code, 0) + assert.ok(/INVALID/.test(out), out) + }) + + it('reports the missing address when stdin is empty and there is no TTY', async () => { + const {code, out} = await run(['validate'], '') + assert.notStrictEqual(code, 0) + assert.ok(/Missing QRL address to validate/.test(out), out) + }) +}) + +describe('validate: the interactive prompt', () => { + it('validates an address typed at the prompt', async () => { + let asked + const {code, out} = await runInteractive([], async options => { + asked = options + return {address: VALID} + }) + assert.strictEqual(code, 0) + assert.ok(/VALID/.test(out), out) + assert.strictEqual(asked.name, 'address') + assert.strictEqual(asked.type, 'text') + }) + + it('refuses an empty answer at the prompt rather than validating nothing', async () => { + // The prompt's own validator is the only thing standing between an + // accidental and a "validation" of the empty string. + let asked + await runInteractive([], async options => { + asked = options + return {address: VALID} + }) + assert.strictEqual(asked.validate(''), 'Address is required') + assert.strictEqual(asked.validate(VALID), true) + }) + + it('exits non-zero when the prompt is cancelled', async () => { + const {code, out} = await runInteractive([], async () => ({})) + assert.notStrictEqual(code, 0) + assert.ok(/Operation cancelled/.test(out), out) + }) +}) diff --git a/test/functions/grpc.test.js b/test/functions/grpc.test.js new file mode 100644 index 0000000..6872087 --- /dev/null +++ b/test/functions/grpc.test.js @@ -0,0 +1,390 @@ +// /////////////////////////////////////////////////////////////////////////// +// grpc test +// +// src/functions/grpc.js is the client factory every network command goes +// through, and it is the only place the CLI decides whether a node is allowed +// to define the wire format it will then be asked to sign against: the node +// serves its own .proto, the CLI hashes it, and unless that digest is on the +// @theqrl/qrl-proto-sha256 allowlist no client is built. That refusal is a +// security control, so it is tested here directly rather than through a +// command. +// +// Everything in this file is offline. The one test that opens a socket points +// at a closed loopback port; the rest replace the generated Base client with a +// local stand-in, so no server is started and nothing leaves the machine. +// /////////////////////////////////////////////////////////////////////////// + +const assert = require('assert') +const crypto = require('crypto') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const CryptoJS = require('crypto-js') +const grpcJs = require('@grpc/grpc-js') +const {QRLPROTO_SHA256} = require('@theqrl/qrl-proto-sha256') + +const Qrlnode = require('../../src/functions/grpc') + +// A closed port on loopback: a connection attempt fails immediately and never leaves the machine. +const DEAD_NODE = '127.0.0.1:1' + +// What the stand-in node "serves" when a test wants the proto exchange to succeed. It only has +// to parse and expose qrl.PublicAPI — grpc.js builds its client from whatever the node sends. +const SERVED_PROTO = `syntax = "proto3"; + +package qrl; + +service PublicAPI { + rpc GetNodeState (GetNodeStateReq) returns (GetNodeStateResp); +} + +message GetNodeStateReq {} + +message GetNodeStateResp { + string version = 1; +} +` + +// checkProtoHash() digests the served proto with CryptoJS. A test that wants the digest to be +// accepted installs both this digest and a plain SHA-256 of the same bytes on the allowlist, so +// it keeps working if the digest is ever corrected (see the note on WordArray.create below). +const moduleDigest = text => CryptoJS.SHA256(CryptoJS.lib.WordArray.create(text)).toString(CryptoJS.enc.Hex) +const contentDigest = text => crypto.createHash('sha256').update(text).digest('hex') + +// The constructor reads process.argv to find out whether the user pinned a network on the command +// line. Under mocha, argv carries the runner's own flags (`-t` is mocha's timeout), so every test +// states the argv it means instead of inheriting however mocha happened to be invoked. +function makeNode(ipAddress, argv = ['node', 'qrl-cli', '--grpc', ipAddress]) { + const savedArgv = process.argv + process.argv = argv + try { + return new Qrlnode(ipAddress) + } finally { + process.argv = savedArgv + } +} + +// Replaces the generated qrl.Base client with a local object, so loadGrpcBaseProto() gets an +// answer without a socket. +function installFakeNode(getNodeInfo) { + const realLoad = grpcJs.loadPackageDefinition + const loadDescriptor = Object.getOwnPropertyDescriptor(grpcJs, 'loadPackageDefinition') + Object.defineProperty(grpcJs, 'loadPackageDefinition', { + configurable: true, + enumerable: true, + value: definition => { + const packageObject = realLoad(definition) + if (packageObject.qrl && packageObject.qrl.Base) { + packageObject.qrl.Base = function FakeBaseClient() { + return {getNodeInfo} + } + } + return packageObject + }, + }) + + return () => { + Object.defineProperty(grpcJs, 'loadPackageDefinition', loadDescriptor) + } +} + +// A node that answers the proto exchange with `protoText`. +const servingNode = protoText => (request, callback) => callback(null, {grpcProto: protoText}) + +// grpc.js writes the proto the node served into a temp file, but returns the path without +// awaiting that write, so the hash check occasionally reads the file before it is filled and +// refuses a proto that is in fact allowlisted. Every command in the CLI papers over this with a +// five-attempt reconnect loop; do the same here rather than let it show up as a flaky test. +async function connectWithRetry(node, attempts = 5) { + let client = null + for (let attempt = 0; attempt < attempts && client === null; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + client = await node.connect() + } + return client +} + +describe('functions/grpc proto allowlist', () => { + let restore = () => {} + let allowlist + + beforeEach(() => { + allowlist = QRLPROTO_SHA256.slice() + }) + + afterEach(() => { + restore() + restore = () => {} + // Entries are only ever replaced with copies below, so the originals are still intact. + QRLPROTO_SHA256.length = 0 + QRLPROTO_SHA256.push(...allowlist) + }) + + it('builds no client when the node serves a proto that is not on the allowlist', async () => { + assert.ok( + !QRLPROTO_SHA256.some(entry => entry.protoHash === moduleDigest(SERVED_PROTO)), + 'the proto used by this test must not already be allowlisted' + ) + restore = installFakeNode(servingNode(SERVED_PROTO)) + + const node = makeNode(DEAD_NODE) + const client = await node.connect() + + assert.strictEqual(client, null, 'an unrecognised proto must not yield a client') + assert.strictEqual(node.connection, false) + assert.strictEqual(node.client, null) + }) + + it('builds a PublicAPI client when the served proto is on the allowlist', async () => { + restore = installFakeNode(servingNode(SERVED_PROTO)) + QRLPROTO_SHA256.push({version: 'test', protoHash: moduleDigest(SERVED_PROTO)}) + QRLPROTO_SHA256.push({version: 'test', protoHash: contentDigest(SERVED_PROTO)}) + + const node = makeNode(DEAD_NODE) + const client = await connectWithRetry(node) + + assert.ok(client, 'an allowlisted proto must yield a client') + assert.strictEqual(node.connection, true) + assert.strictEqual(typeof client.GetNodeState, 'function') + assert.strictEqual(client.GetNodeState.path, '/qrl.PublicAPI/GetNodeState') + // The client is lazy — nothing has been dialled — but close it so no channel is left behind. + client.close() + }) + + it('builds no client when the compiled proto object is not on the allowlist', async () => { + restore = installFakeNode(servingNode(SERVED_PROTO)) + // Let the served file through, then take the compiled object's digest off the allowlist: + // loadGrpcProto() must refuse rather than hand back a client. + const withoutCliProto = QRLPROTO_SHA256.map(entry => + entry.cliProto ? {...entry, cliProto: 'f'.repeat(64)} : {...entry} + ) + QRLPROTO_SHA256.length = 0 + QRLPROTO_SHA256.push(...withoutCliProto) + QRLPROTO_SHA256.push({version: 'test', protoHash: moduleDigest(SERVED_PROTO)}) + QRLPROTO_SHA256.push({version: 'test', protoHash: contentDigest(SERVED_PROTO)}) + + // Retried for the same reason connectWithRetry() exists: an attempt can fail early, at the + // served-file check, and this test is about the later one on the compiled object. + const node = makeNode(DEAD_NODE) + for (let attempt = 0; attempt < 5; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + assert.strictEqual(await node.connect(), null) + assert.strictEqual(node.connection, false) + } + }) + + it('rejects when the node fails the proto exchange', async () => { + restore = installFakeNode((request, callback) => callback(new Error('node refused getNodeInfo'))) + + const node = makeNode(DEAD_NODE) + await assert.rejects(node.connect(), /node refused getNodeInfo/) + assert.strictEqual(node.connection, false) + }) + + it('rejects when nothing is listening on the endpoint', async () => { + const node = makeNode(DEAD_NODE) + await assert.rejects(node.connect(), /UNAVAILABLE|ECONNREFUSED/) + assert.strictEqual(node.connection, false) + }) +}) + +describe('functions/grpc connection state', () => { + it('refuses a second connect on a connected node', async () => { + const node = makeNode(DEAD_NODE) + node.connection = true + await assert.rejects(node.connect(), /Already connected/) + }) + + it('drops the client on disconnect', () => { + const node = makeNode(DEAD_NODE) + node.connection = true + node.client = {GetHeight: () => {}} + + node.disconnect() + + assert.strictEqual(node.connection, false) + assert.strictEqual(node.client, null) + }) +}) + +describe('functions/grpc validApi', () => { + it('accepts a method served by the qrl package', async () => { + const node = makeNode(DEAD_NODE) + node.client = {GetHeight: {path: '/qrl.PublicAPI/GetHeight'}} + assert.strictEqual(await node.validApi('GetHeight'), true) + }) + + it('rejects a method served by some other package', async () => { + const node = makeNode(DEAD_NODE) + node.client = {GetHeight: {path: '/evil.PublicAPI/GetHeight'}} + assert.strictEqual(await node.validApi('GetHeight'), false) + }) + + it('rejects any method when there is no client', async () => { + const node = makeNode(DEAD_NODE) + assert.strictEqual(await node.validApi('GetHeight'), false) + }) +}) + +describe('functions/grpc api', () => { + it('resolves with what the node returned', async () => { + const node = makeNode(DEAD_NODE) + node.connection = true + node.client = { + GetHeight: (request, callback) => callback(null, {height: 42, echoed: request}), + } + + assert.deepStrictEqual(await node.api('GetHeight', {query: 'x'}), {height: 42, echoed: {query: 'x'}}) + // No request given: the call still goes out, with an empty one. + assert.deepStrictEqual(await node.api('GetHeight'), {height: 42, echoed: {}}) + }) + + it('rejects when the node returns an error', async () => { + const node = makeNode(DEAD_NODE) + node.connection = true + node.client = { + PushTransaction: (request, callback) => callback(new Error('16 UNAUTHENTICATED')), + } + + await assert.rejects(node.api('PushTransaction', {}), /UNAUTHENTICATED/) + }) + + it('rejects for a method the node does not serve', async () => { + const node = makeNode(DEAD_NODE) + node.connection = true + node.client = {GetHeight: (request, callback) => callback(null, {})} + + await assert.rejects(node.api('NotAMethod', {}), /not a function/) + }) + + it('reconnects, and fails, when called before connect', async () => { + // api() calls connect() but does not await it, so the reconnect path can only ever fail: + // it indexes the pending promise as if it were the client. Pinned deliberately — if api() + // is fixed to await, this should become an assertion on the resolved response. + const node = makeNode(DEAD_NODE) + let connectCalls = 0 + node.connect = () => { + connectCalls += 1 + return Promise.resolve({GetHeight: (request, callback) => callback(null, {height: 1})}) + } + + await assert.rejects(node.api('GetHeight', {}), /not a function/) + assert.strictEqual(connectCalls, 1, 'api() must have tried to reconnect') + }) +}) + +// The endpoint a QrlNode talks to is a security property: the default is a remote node reached +// over plaintext gRPC, and the config file and environment can each redirect it. These tests +// point the config directory at a temp dir so the developer's real qrl-cli config is untouched. +describe('functions/grpc endpoint precedence', () => { + const CONFIG_ENV = ['XDG_CONFIG_HOME', 'HOME', 'APPDATA', 'LOCALAPPDATA'] + const NETWORK_ENV = ['QRL_NETWORK', 'QRL_GRPC_ENDPOINT'] + const MAINNET_ENDPOINT = 'mainnet-3.automated.theqrl.org:19009' + const TESTNET_ENDPOINT = 'testnet-3.automated.theqrl.org:19009' + const GIVEN = 'given.example.org:19009' + // No network flag on the command line: this is what lets the config and environment be read. + const NO_FLAGS = ['node', 'qrl-cli', 'status'] + + let saved + let tempHome + let config + + before(() => { + saved = {} + CONFIG_ENV.concat(NETWORK_ENV).forEach(key => { + saved[key] = process.env[key] + }) + // env-paths picks a different variable per platform, so redirect all of them. + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qrl-cli-grpc-')) + CONFIG_ENV.forEach(key => { + process.env[key] = tempHome + }) + NETWORK_ENV.forEach(key => { + delete process.env[key] + }) + + // eslint-disable-next-line global-require + const Conf = require('conf') + config = new Conf({projectName: 'qrl-cli'}) + assert.ok(config.path.startsWith(tempHome), 'config must be isolated to the temp dir') + }) + + beforeEach(() => { + config.clear() + NETWORK_ENV.forEach(key => { + delete process.env[key] + }) + }) + + after(() => { + config.clear() + CONFIG_ENV.concat(NETWORK_ENV).forEach(key => { + if (saved[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = saved[key] + } + }) + fs.rmSync(tempHome, {recursive: true, force: true}) + }) + + it('keeps the given endpoint when nothing else is configured', () => { + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, GIVEN) + }) + + it('prefers QRL_GRPC_ENDPOINT over everything else', () => { + process.env.QRL_GRPC_ENDPOINT = 'env.example.org:19009' + process.env.QRL_NETWORK = 'testnet' + config.set('grpc-endpoint', 'config.example.org:19009') + config.set('default-network', 'mainnet') + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, 'env.example.org:19009') + }) + + it('falls back to the configured grpc-endpoint', () => { + process.env.QRL_NETWORK = 'testnet' + config.set('grpc-endpoint', 'config.example.org:19009') + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, 'config.example.org:19009') + }) + + it('uses testnet from QRL_NETWORK', () => { + process.env.QRL_NETWORK = 'testnet' + config.set('default-network', 'mainnet') + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, TESTNET_ENDPOINT) + }) + + it('uses testnet from the config file', () => { + config.set('default-network', 'testnet') + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, TESTNET_ENDPOINT) + }) + + it('uses mainnet from QRL_NETWORK', () => { + process.env.QRL_NETWORK = 'mainnet' + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, MAINNET_ENDPOINT) + }) + + it('uses mainnet from the config file', () => { + config.set('default-network', 'mainnet') + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, MAINNET_ENDPOINT) + }) + + it('ignores an unknown configured network', () => { + config.set('default-network', 'devnet') + assert.strictEqual(makeNode(GIVEN, NO_FLAGS).ipAddress, GIVEN) + }) + + it('lets an explicit --grpc flag override the config', () => { + config.set('grpc-endpoint', 'config.example.org:19009') + assert.strictEqual(makeNode(GIVEN, ['node', 'qrl-cli', 'status', '--grpc', GIVEN]).ipAddress, GIVEN) + }) + + it('lets an explicit -t flag override the config', () => { + config.set('grpc-endpoint', 'config.example.org:19009') + assert.strictEqual(makeNode(GIVEN, ['node', 'qrl-cli', 'status', '-t']).ipAddress, GIVEN) + }) + + it('lets an explicit -m flag override the config', () => { + config.set('grpc-endpoint', 'config.example.org:19009') + assert.strictEqual(makeNode(GIVEN, ['node', 'qrl-cli', 'status', '-m']).ipAddress, GIVEN) + }) +}) diff --git a/test/helpers/offline-dns.js b/test/helpers/offline-dns.js new file mode 100644 index 0000000..62ef5d8 --- /dev/null +++ b/test/helpers/offline-dns.js @@ -0,0 +1,55 @@ +// /////////////////////////////////////////////////////////////////////////// +// offline-dns +// +// A --require preload for CLI child processes: every hostname that is not an +// IP literal or `localhost` fails to resolve, exactly as it would on a machine +// with no network. +// +// Commands that take -t/--testnet or -m/--mainnet hard-code the public QRL +// endpoints and ignore --grpc, so there is no flag that keeps those code paths +// on the machine. Preloading this means the connection-failure path can be +// tested for real without the test ever reaching mainnet or testnet. +// /////////////////////////////////////////////////////////////////////////// + +const dns = require('dns') + +const notFound = () => + Object.assign(new Error('getaddrinfo ENOTFOUND (blocked: offline test)'), {code: 'ENOTFOUND'}) + +const isRemoteName = host => + typeof host === 'string' && host !== 'localhost' && !/^\d+\.\d+\.\d+\.\d+$/.test(host) + +const realLookup = dns.lookup +dns.lookup = function lookup(host, ...rest) { + const callback = rest[rest.length - 1] + if (isRemoteName(host) && typeof callback === 'function') { + process.nextTick(() => callback(notFound())) + return undefined + } + return realLookup.call(dns, host, ...rest) +} + +if (dns.promises) { + const realLookupAsync = dns.promises.lookup + dns.promises.lookup = (host, ...rest) => + (isRemoteName(host) + ? Promise.reject(notFound()) + : realLookupAsync.call(dns.promises, host, ...rest)) +} + +const RESOLVERS = ['resolve', 'resolve4', 'resolve6', 'resolveTxt', 'resolveSrv'] + +RESOLVERS.forEach(name => { + const real = dns[name] + if (!real) { + return + } + dns[name] = function resolver(host, ...rest) { + const callback = rest[rest.length - 1] + if (isRemoteName(host) && typeof callback === 'function') { + process.nextTick(() => callback(notFound())) + return undefined + } + return real.call(dns, host, ...rest) + } +}) diff --git a/test/utils/silent-eccrypto.test.js b/test/utils/silent-eccrypto.test.js new file mode 100644 index 0000000..299a62d --- /dev/null +++ b/test/utils/silent-eccrypto.test.js @@ -0,0 +1,197 @@ +// /////////////////////////////////////////////////////////////////////////// +// silent-eccrypto test +// +// src/utils/silent-eccrypto.js exists to stop eccrypto's +// "secp256k1 unavailable, reverting to browser version" notice from landing in +// the middle of CLI output, while still recording *which* implementation was +// resolved. That record is the only signal a user gets that key generation has +// dropped from the native secp256k1 binding to the pure-JS elliptic fallback, +// so the capture, the reporting and the pass-through all need to be pinned. +// +// Which implementation this machine actually resolves is a property of the +// platform, not of the loader, so the interesting cases are driven by faking +// the eccrypto require rather than by hoping the host is (or is not) missing +// ecdh.node. Offline and deterministic: no node, no network, no wallet files. +// /////////////////////////////////////////////////////////////////////////// + +/* eslint-disable no-console */ + +const assert = require('assert') +const Module = require('module') + +const LOADER = require.resolve('../../src/utils/silent-eccrypto') +const FALLBACK_NOTICE = 'secp256k1 unavailable, reverting to browser version' +const NATIVE = 'native (secp256k1 ecdh.node)' +const BROWSER = 'browser (elliptic, pure JS)' + +// Load a fresh copy of the loader. +// +// When `emit` is given, `require('eccrypto')` is intercepted and `emit` runs in +// its place -- that is the exact window in which the loader has console.info +// hooked, so whatever `emit` prints stands in for what eccrypto itself would +// print while initialising. Returning a stub also keeps the real eccrypto +// module (and its cached export object) untouched. +function loadLoader(emit) { + delete require.cache[LOADER] + const realRequire = Module.prototype.require + if (emit) { + Module.prototype.require = function patchedRequire(...args) { + if (args[0] === 'eccrypto') { + emit() + return {stubbedEccrypto: true} + } + return realRequire.apply(this, args) + } + } + try { + return require('../../src/utils/silent-eccrypto') // eslint-disable-line global-require + } finally { + Module.prototype.require = realRequire + } +} + +// Run `fn` with console.info replaced, and hand back everything it was called +// with. The loader snapshots console.info as it loads, so a stub installed here +// is the "original" it restores and forwards to. +function capturingConsoleInfo(fn) { + const calls = [] + const real = console.info + console.info = (...args) => { + calls.push(args) + } + try { + fn() + } finally { + console.info = real + } + return calls +} + +describe('utils/silent-eccrypto', () => { + // The loader must not leave its hook installed, whatever happened during load. + afterEach(() => { + delete require.cache[LOADER] + }) + + describe('loading the real module', () => { + it('exports a usable eccrypto and restores console.info', () => { + const before = console.info + const eccrypto = loadLoader() + assert.strictEqual(console.info, before, 'console.info was left hooked') + assert.strictEqual(typeof eccrypto.generatePrivate, 'function') + assert.strictEqual(typeof eccrypto.getPublic, 'function') + }) + + it('reports the implementation it resolved', () => { + const eccrypto = loadLoader() + assert.strictEqual(typeof eccrypto.usingFallback, 'boolean') + assert.strictEqual(eccrypto.implementation, eccrypto.usingFallback ? BROWSER : NATIVE) + }) + }) + + describe('the fallback notice', () => { + it('is swallowed, and recorded as the browser implementation', () => { + let loaded + const printed = capturingConsoleInfo(() => { + loaded = loadLoader(() => console.info(FALLBACK_NOTICE)) + }) + assert.deepStrictEqual(printed, [], 'the notice reached the console') + assert.strictEqual(loaded.usingFallback, true) + assert.strictEqual(loaded.implementation, BROWSER) + }) + + it('is matched anywhere in the message, not only at the start', () => { + let loaded + const printed = capturingConsoleInfo(() => { + loaded = loadLoader(() => console.info(`eccrypto: ${FALLBACK_NOTICE} (v1.1.6)`)) + }) + assert.deepStrictEqual(printed, []) + assert.strictEqual(loaded.implementation, BROWSER) + }) + + it('is absent when eccrypto says nothing, and the native binding is reported', () => { + let loaded + const printed = capturingConsoleInfo(() => { + loaded = loadLoader(() => {}) + }) + assert.deepStrictEqual(printed, []) + assert.strictEqual(loaded.usingFallback, false) + assert.strictEqual(loaded.implementation, NATIVE) + }) + }) + + describe('unrelated console.info output during load', () => { + it('is passed through, arguments and all', () => { + let loaded + const printed = capturingConsoleInfo(() => { + loaded = loadLoader(() => console.info('loading %s', 'something else')) + }) + assert.deepStrictEqual(printed, [['loading %s', 'something else']]) + assert.strictEqual(loaded.usingFallback, false) + }) + + it('is passed through when the message is not a string', () => { + const notAString = {code: 'ENOENT'} + let loaded + const printed = capturingConsoleInfo(() => { + loaded = loadLoader(() => console.info(notAString)) + }) + assert.deepStrictEqual(printed, [[notAString]]) + assert.strictEqual(loaded.usingFallback, false) + }) + }) + + describe('verbose reporting', () => { + const originalEnv = process.env.QRL_CLI_VERBOSE + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.QRL_CLI_VERBOSE + } else { + process.env.QRL_CLI_VERBOSE = originalEnv + } + }) + + // Each of the three triggers is checked on its own: they are OR'd together, + // so a broken one is invisible while any other is also set. + it('says nothing by default', () => { + delete process.env.QRL_CLI_VERBOSE + const printed = capturingConsoleInfo(() => loadLoader(() => {})) + assert.deepStrictEqual(printed, []) + }) + + it('names the implementation when QRL_CLI_VERBOSE=1', () => { + process.env.QRL_CLI_VERBOSE = '1' + const printed = capturingConsoleInfo(() => loadLoader(() => console.info(FALLBACK_NOTICE))) + assert.deepStrictEqual(printed, [[`eccrypto secp256k1 implementation: ${BROWSER}`]]) + }) + + it('stays quiet for any other value of QRL_CLI_VERBOSE', () => { + process.env.QRL_CLI_VERBOSE = 'true' + const printed = capturingConsoleInfo(() => loadLoader(() => {})) + assert.deepStrictEqual(printed, []) + }) + + it('names the implementation when --verbose is on the command line', () => { + delete process.env.QRL_CLI_VERBOSE + process.argv.push('--verbose') + try { + const printed = capturingConsoleInfo(() => loadLoader(() => {})) + assert.deepStrictEqual(printed, [[`eccrypto secp256k1 implementation: ${NATIVE}`]]) + } finally { + process.argv.pop() + } + }) + + it('names the implementation when -v is on the command line', () => { + delete process.env.QRL_CLI_VERBOSE + process.argv.push('-v') + try { + const printed = capturingConsoleInfo(() => loadLoader(() => {})) + assert.deepStrictEqual(printed, [[`eccrypto secp256k1 implementation: ${NATIVE}`]]) + } finally { + process.argv.pop() + } + }) + }) +})