diff --git a/app.js b/app.js index 9519be1..7c73f44 100644 --- a/app.js +++ b/app.js @@ -1,252 +1,10 @@ -'use strict'; - -var _ = require('lodash'); -var async = require('async'); -var log = require('npmlog'); -var express = require('express'); -var querystring = require('querystring'); -var bodyParser = require('body-parser') - -var CopayServer = require('./lib/server'); - -log.debug = log.verbose; -log.level = 'debug'; - - -CopayServer.initialize(); - - -var app = express(); -app.use(function(req, res, next) { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); - res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,Content-Type,Authorization'); - next(); -}); -var allowCORS = function(req, res, next) { - if ('OPTIONS' == req.method) { - res.send(200); - res.end(); - return; - } - next(); -} -app.use(allowCORS); - -var POST_LIMIT = 1024 * 100 /* Max POST 100 kb */ ; - -app.use(bodyParser.json({ - limit: POST_LIMIT -})); - -app.use(require('morgan')('dev')); +#!/usr/bin/env node +var ExpressApp = require('./lib/expressapp'); var port = process.env.COPAY_PORT || 3001; -var router = express.Router(); - -function returnError(err, res, req) { - if (err instanceof CopayServer.ClientError) { - - var status = (err.code == 'NOTAUTHORIZED') ? 401 : 400; - log.error('Err: ' + status + ':' + req.url + ' :' + err.code + ':' + err.message); - res.status(status).json({ - code: err.code, - error: err.message, - }).end(); - } else { - var code, message; - if (_.isObject(err)) { - code = err.code; - message = err.message; - } - var m = message || err.toString(); - - log.error('Error: ' + req.url + ' :' + code + ':' + m); - res.status(code || 500).json({ - error: m, - }).end(); - } -}; - -function getCredentials(req) { - var identity = req.header('x-identity'); - if (!identity) return; - - return { - copayerId: identity, - signature: req.header('x-signature'), - }; -}; - -function getServerWithAuth(req, res, cb) { - var credentials = getCredentials(req); - var auth = { - copayerId: credentials.copayerId, - message: req.method.toLowerCase() + '|' + req.url + '|' + JSON.stringify(req.body), - signature: credentials.signature, - }; - CopayServer.getInstanceWithAuth(auth, function(err, server) { - if (err) return returnError(err, res, req); - return cb(server); - }); -}; - -router.post('/v1/wallets/', function(req, res) { - var server = CopayServer.getInstance(); - server.createWallet(req.body, function(err, walletId) { - if (err) return returnError(err, res, req); - - res.json({ - walletId: walletId, - }); - }); -}); - -router.post('/v1/wallets/:id/copayers/', function(req, res) { - req.body.walletId = req.params['id']; - var server = CopayServer.getInstance(); - server.joinWallet(req.body, function(err, result) { - if (err) return returnError(err, res, req); - - res.json(result); - }); -}); - -router.get('/v1/wallets/', function(req, res) { - getServerWithAuth(req, res, function(server) { - var result = {}; - async.parallel([ - - function(next) { - server.getWallet({}, function(err, wallet) { - if (err) return next(err); - result.wallet = wallet; - next(); - }); - }, - function(next) { - server.getBalance({}, function(err, balance) { - if (err) return next(err); - result.balance = balance; - next(); - }); - }, - function(next) { - server.getPendingTxs({}, function(err, pendingTxps) { - if (err) return next(err); - result.pendingTxps = pendingTxps; - next(); - }); - }, - ], function(err) { - if (err) return returnError(err, res, req); - res.json(result); - }); - }); -}); - - -router.get('/v1/txproposals/', function(req, res) { - getServerWithAuth(req, res, function(server) { - server.getPendingTxs({}, function(err, pendings) { - if (err) return returnError(err, res, req); - res.json(pendings); - }); - }); -}); - - -router.post('/v1/txproposals/', function(req, res) { - getServerWithAuth(req, res, function(server) { - server.createTx(req.body, function(err, txp) { - if (err) return returnError(err, res, req); - res.json(txp); - }); - }); -}); - - -router.post('/v1/addresses/', function(req, res) { - getServerWithAuth(req, res, function(server) { - server.createAddress(req.body, function(err, address) { - if (err) return returnError(err, res, req); - res.json(address); - }); - }); -}); - -router.get('/v1/addresses/', function(req, res) { - getServerWithAuth(req, res, function(server) { - server.getAddresses({}, function(err, addresses) { - if (err) return returnError(err, res, req); - res.json(addresses); - }); - }); -}); - -router.get('/v1/balance/', function(req, res) { - getServerWithAuth(req, res, function(server) { - server.getBalance({}, function(err, balance) { - if (err) return returnError(err, res, req); - res.json(balance); - }); - }); -}); - -router.post('/v1/txproposals/:id/signatures/', function(req, res) { - getServerWithAuth(req, res, function(server) { - req.body.txProposalId = req.params['id']; - server.signTx(req.body, function(err, txp) { - if (err) return returnError(err, res, req); - res.json(txp); - res.end(); - }); - }); -}); - -// TODO Check HTTP verb and URL name -router.post('/v1/txproposals/:id/broadcast/', function(req, res) { - getServerWithAuth(req, res, function(server) { - req.body.txProposalId = req.params['id']; - server.broadcastTx(req.body, function(err, txp) { - if (err) return returnError(err, res, req); - res.json(txp); - res.end(); - }); - }); -}); - -router.post('/v1/txproposals/:id/rejections', function(req, res) { - getServerWithAuth(req, res, function(server) { - req.body.txProposalId = req.params['id']; - server.rejectTx(req.body, function(err, txp) { - if (err) return returnError(err, res, req); - res.json(txp); - res.end(); - }); - }); -}); - -router.delete('/v1/txproposals/:id/', function(req, res) { - getServerWithAuth(req, res, function(server) { - req.body.txProposalId = req.params['id']; - server.removePendingTx(req.body, function(err) { - if (err) return returnError(err, res, req); - res.end(); - }); - }); -}); - -// TODO: DEBUG only! -router.get('/v1/dump', function(req, res) { - var server = CopayServer.getInstance(); - server.storage._dump(function() { - res.end(); - }); -}); - -app.use('/copay/api', router); +var app = ExpressApp.start(); app.listen(port); + console.log('Copay service running on port ' + port); diff --git a/lib/client/Verifier.js b/lib/client/Verifier.js index 2439497..639b73e 100644 --- a/lib/client/Verifier.js +++ b/lib/client/Verifier.js @@ -35,7 +35,8 @@ Verifier.checkCopayers = function(copayers, walletPrivKey, myXPrivKey, n) { } // Not signed pub keys - if (!WalletUtils.verifyMessage(copayer.xPubKey, copayer.xPubKeySignature, walletPubKey)) { + if (!copayer.xPubKey || !copayer.xPubKeySignature || + !WalletUtils.verifyMessage(copayer.xPubKey, copayer.xPubKeySignature, walletPubKey)) { log.error('Invalid signatures in server response'); error = true; } @@ -53,11 +54,12 @@ Verifier.checkCopayers = function(copayers, walletPrivKey, myXPrivKey, n) { Verifier.checkTxProposal = function(data, txp) { + $.checkArgument(txp.creatorId); + var creatorXPubKey = _.find(data.publicKeyRing, function(xPubKey) { if (WalletUtils.xPubToCopayerId(xPubKey) === txp.creatorId) return true; }); if (!creatorXPubKey) return false; - var creatorSigningPubKey = (new Bitcore.HDPublicKey(creatorXPubKey)).derive('m/1/0').publicKey.toString(); var hash = WalletUtils.getProposalHash(txp.toAddress, txp.amount, txp.message); diff --git a/lib/client/api.js b/lib/client/api.js index f2224ff..3530582 100644 --- a/lib/client/api.js +++ b/lib/client/api.js @@ -60,8 +60,11 @@ function API(opts) { this.verbose = !!opts.verbose; this.request = request || opts.request; this.baseUrl = opts.baseUrl || BASE_URL; + this.basePath = this.baseUrl.replace(/http.?:\/\/[a-zA-Z0-9:-]*\//,'/'); if (this.verbose) { log.level = 'debug'; + } else { + log.level = 'info'; } }; @@ -127,6 +130,8 @@ API.prototype._doRequest = function(method, url, args, data, cb) { var absUrl = this.baseUrl + url; var args = { + // relUrl: only for testing with `supertest` + relUrl: this.basePath + url, headers: { 'x-identity': data.copayerId, 'x-signature': reqSignature, @@ -415,7 +420,7 @@ API.prototype.import = function(str, cb) { var xPubKey = (new Bitcore.HDPublicKey(data.xPrivKey)).toString(); - data.publicKeyRing.push(xPubKey); + data.publicKeyRing.unshift(xPubKey); data.copayerId = WalletUtils.xPubToCopayerId(xPubKey); data.n = data.publicKeyRing.length; data.signingPrivKey = (new Bitcore.HDPrivateKey(data.xPrivKey)).derive('m/1/0').privateKey.toWIF(); @@ -444,6 +449,7 @@ API.prototype.getTxProposals = function(opts, cb) { API.prototype.signTxProposal = function(txp, cb) { var self = this; + $.checkArgument(txp.creatorId); this._loadAndCheck(function(err, data) { if (err) return cb(err); @@ -452,7 +458,6 @@ API.prototype.signTxProposal = function(txp, cb) { return cb(new ServerCompromisedError('Server sent fake transaction proposal')); } - //Derive proper key to sign, for each input var privs = [], derived = {}; @@ -493,6 +498,7 @@ API.prototype.signTxProposal = function(txp, cb) { API.prototype.rejectTxProposal = function(txp, reason, cb) { var self = this; + $.checkArgument(cb); this._loadAndCheck( function(err, data) { diff --git a/lib/expressapp.js b/lib/expressapp.js new file mode 100644 index 0000000..07f9de3 --- /dev/null +++ b/lib/expressapp.js @@ -0,0 +1,259 @@ +'use strict'; + +var _ = require('lodash'); +var async = require('async'); +var log = require('npmlog'); +var express = require('express'); +var querystring = require('querystring'); +var bodyParser = require('body-parser') + +var CopayServer = require('./server'); + +log.debug = log.verbose; +log.level = 'debug'; + +var ExpressApp = function() {}; + +ExpressApp.start = function(opts) { + opts = opts || {}; + + + CopayServer.initialize(opts.CopayServer); + var app = express(); + app.use(function(req, res, next) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,Content-Type,Authorization'); + next(); + }); + var allowCORS = function(req, res, next) { + if ('OPTIONS' == req.method) { + res.send(200); + res.end(); + return; + } + next(); + } + app.use(allowCORS); + + var POST_LIMIT = 1024 * 100 /* Max POST 100 kb */ ; + + app.use(bodyParser.json({ + limit: POST_LIMIT + })); + + app.use(require('morgan')('dev')); + + + var router = express.Router(); + + function returnError(err, res, req) { + if (err instanceof CopayServer.ClientError) { + + var status = (err.code == 'NOTAUTHORIZED') ? 401 : 400; + log.error('Err: ' + status + ':' + req.url + ' :' + err.code + ':' + err.message); + res.status(status).json({ + code: err.code, + error: err.message, + }).end(); + } else { + var code, message; + if (_.isObject(err)) { + code = err.code; + message = err.message; + } + var m = message || err.toString(); + + log.error('Error: ' + req.url + ' :' + code + ':' + m); + res.status(code || 500).json({ + error: m, + }).end(); + } + }; + + function getCredentials(req) { + var identity = req.header('x-identity'); + if (!identity) return; + + return { + copayerId: identity, + signature: req.header('x-signature'), + }; + }; + + function getServerWithAuth(req, res, cb) { + var credentials = getCredentials(req); + if (!credentials) + return returnError(new CopayServer.ClientError({ + code: 'NOTAUTHORIZED' + }), res, req); + + var auth = { + copayerId: credentials.copayerId, + message: req.method.toLowerCase() + '|' + req.url + '|' + JSON.stringify(req.body), + signature: credentials.signature, + }; + CopayServer.getInstanceWithAuth(auth, function(err, server) { + if (err) return returnError(err, res, req); + return cb(server); + }); + }; + + router.post('/v1/wallets/', function(req, res) { + var server = CopayServer.getInstance(); + server.createWallet(req.body, function(err, walletId) { + if (err) return returnError(err, res, req); + + res.json({ + walletId: walletId, + }); + }); + }); + + router.post('/v1/wallets/:id/copayers/', function(req, res) { + req.body.walletId = req.params['id']; + var server = CopayServer.getInstance(); + server.joinWallet(req.body, function(err, result) { + if (err) return returnError(err, res, req); + + res.json(result); + }); + }); + + router.get('/v1/wallets/', function(req, res) { + getServerWithAuth(req, res, function(server) { + var result = {}; + async.parallel([ + + function(next) { + server.getWallet({}, function(err, wallet) { + if (err) return next(err); + result.wallet = wallet; + next(); + }); + }, + function(next) { + server.getBalance({}, function(err, balance) { + if (err) return next(err); + result.balance = balance; + next(); + }); + }, + function(next) { + server.getPendingTxs({}, function(err, pendingTxps) { + if (err) return next(err); + result.pendingTxps = pendingTxps; + next(); + }); + }, + ], function(err) { + if (err) return returnError(err, res, req); + res.json(result); + }); + }); + }); + + + router.get('/v1/txproposals/', function(req, res) { + getServerWithAuth(req, res, function(server) { + server.getPendingTxs({}, function(err, pendings) { + if (err) return returnError(err, res, req); + res.json(pendings); + }); + }); + }); + + + router.post('/v1/txproposals/', function(req, res) { + getServerWithAuth(req, res, function(server) { + server.createTx(req.body, function(err, txp) { + if (err) return returnError(err, res, req); + res.json(txp); + }); + }); + }); + + + router.post('/v1/addresses/', function(req, res) { + getServerWithAuth(req, res, function(server) { + server.createAddress(req.body, function(err, address) { + if (err) return returnError(err, res, req); + res.json(address); + }); + }); + }); + + router.get('/v1/addresses/', function(req, res) { + getServerWithAuth(req, res, function(server) { + server.getAddresses({}, function(err, addresses) { + if (err) return returnError(err, res, req); + res.json(addresses); + }); + }); + }); + + router.get('/v1/balance/', function(req, res) { + getServerWithAuth(req, res, function(server) { + server.getBalance({}, function(err, balance) { + if (err) return returnError(err, res, req); + res.json(balance); + }); + }); + }); + + router.post('/v1/txproposals/:id/signatures/', function(req, res) { + getServerWithAuth(req, res, function(server) { + req.body.txProposalId = req.params['id']; + server.signTx(req.body, function(err, txp) { + if (err) return returnError(err, res, req); + res.json(txp); + res.end(); + }); + }); + }); + + // TODO Check HTTP verb and URL name + router.post('/v1/txproposals/:id/broadcast/', function(req, res) { + getServerWithAuth(req, res, function(server) { + req.body.txProposalId = req.params['id']; + server.broadcastTx(req.body, function(err, txp) { + if (err) return returnError(err, res, req); + res.json(txp); + res.end(); + }); + }); + }); + + router.post('/v1/txproposals/:id/rejections', function(req, res) { + getServerWithAuth(req, res, function(server) { + req.body.txProposalId = req.params['id']; + server.rejectTx(req.body, function(err, txp) { + if (err) return returnError(err, res, req); + res.json(txp); + res.end(); + }); + }); + }); + + router.delete('/v1/txproposals/:id/', function(req, res) { + getServerWithAuth(req, res, function(server) { + req.body.txProposalId = req.params['id']; + server.removePendingTx(req.body, function(err) { + if (err) return returnError(err, res, req); + res.end(); + }); + }); + }); + + // TODO: DEBUG only! + router.get('/v1/dump', function(req, res) { + var server = CopayServer.getInstance(); + server.storage._dump(function() { + res.end(); + }); + }); + app.use(opts.base_path || '/copay/api', router); + return app; +}; + +module.exports = ExpressApp; diff --git a/lib/server.js b/lib/server.js index bc3a287..486e762 100644 --- a/lib/server.js +++ b/lib/server.js @@ -26,15 +26,18 @@ var TxProposal = require('./model/txproposal'); var Notification = require('./model/notification'); var initialized = false; -var storage; +var storage, blockExplorer; /** * Creates an instance of the Copay server. * @constructor */ function CopayServer() { - if (!initialized) throw new Error('Server not initialized'); + if (!initialized) + throw new Error('Server not initialized'); + this.storage = storage; + this.blockExplorer = blockExplorer; this.notifyTicker = 0; }; @@ -45,10 +48,12 @@ nodeutil.inherits(CopayServer, events.EventEmitter); * Initializes global settings for all instances. * @param {Object} opts * @param {Storage} [opts.storage] - The storage provider. + * @param {Storage} [opts.blockExplorer] - The blockExporer provider. */ CopayServer.initialize = function(opts) { opts = opts || {}; storage = opts.storage ||  new Storage(); + blockExplorer = opts.blockExplorer; initialized = true; }; @@ -311,6 +316,9 @@ CopayServer.prototype.verifyMessageSignature = function(opts, cb) { CopayServer.prototype._getBlockExplorer = function(provider, network) { var url; + if (this.blockExplorer) + return this.blockExplorer; + switch (provider) { default: case 'insight': @@ -352,7 +360,6 @@ CopayServer.prototype._getUtxos = function(cb) { var utxos = _.map(inutxos, function(i) { return i.toObject(); }); - self.getPendingTxs({}, function(err, txps) { if (err) return cb(err); @@ -773,7 +780,7 @@ CopayServer.prototype.rejectTx = function(opts, cb) { }); }; - return cb(); + return cb(null, txp); }); }); }; diff --git a/lib/walletutils.js b/lib/walletutils.js index 5aacd82..78f4c71 100644 --- a/lib/walletutils.js +++ b/lib/walletutils.js @@ -1,4 +1,5 @@ var _ = require('lodash'); +var $ = require('preconditions').singleton(); var sjcl = require('sjcl'); var Bitcore = require('bitcore'); @@ -14,6 +15,7 @@ function WalletUtils() {}; /* TODO: It would be nice to be compatible with bitcoind signmessage. How * the hash is calculated there? */ WalletUtils.hashMessage = function(text) { + $.checkArgument(text); var buf = new Buffer(text); var ret = crypto.Hash.sha256sha256(buf); ret = new Bitcore.encoding.BufferReader(ret).readReverse(); @@ -22,6 +24,7 @@ WalletUtils.hashMessage = function(text) { WalletUtils.signMessage = function(text, privKey) { + $.checkArgument(text); var priv = new PrivateKey(privKey); var hash = WalletUtils.hashMessage(text); return crypto.ECDSA.sign(hash, priv, 'little').toString(); @@ -29,6 +32,11 @@ WalletUtils.signMessage = function(text, privKey) { WalletUtils.verifyMessage = function(text, signature, pubKey) { + $.checkArgument(text, pubKey); + + if (!signature) + return false; + var pub = new PublicKey(pubKey); var hash = WalletUtils.hashMessage(text); @@ -69,6 +77,7 @@ WalletUtils.toSecret = function(walletId, walletPrivKey, network) { }; WalletUtils.fromSecret = function(secret) { + $.checkArgument(secret); var secretSplit = secret.split(':'); var walletId = secretSplit[0]; var walletPrivKey = Bitcore.PrivateKey.fromString(secretSplit[1]); @@ -104,8 +113,10 @@ WalletUtils.UNITS = { }; WalletUtils.parseAmount = function(text) { - var regex = '^(\\d*(\\.\\d{0,8})?)\\s*(' + _.keys(WalletUtils.UNITS).join('|') + ')?$'; + if (!_.isString(text)) + text = text.toString(); + var regex = '^(\\d*(\\.\\d{0,8})?)\\s*(' + _.keys(WalletUtils.UNITS).join('|') + ')?$'; var match = new RegExp(regex, 'i').exec(text.trim()); if (!match || match.length === 0) throw new Error('Invalid amount'); diff --git a/package.json b/package.json index bcca48e..049b877 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "mocha": "^1.18.2", "sinon": "^1.10.3", "memdown": "^1.0.0", - "jsdoc": "^3.3.0" + "jsdoc": "^3.3.0", + "supertest": "*" }, "scripts": { "start": "node server.js" diff --git a/test/integration/clientApi.js b/test/integration/clientApi.js index dea1aff..16c0288 100644 --- a/test/integration/clientApi.js +++ b/test/integration/clientApi.js @@ -4,133 +4,497 @@ var _ = require('lodash'); var chai = require('chai'); var sinon = require('sinon'); var should = chai.should(); +var levelup = require('levelup'); +var memdown = require('memdown'); +var async = require('async'); +var request = require('supertest'); var Client = require('../../lib/client'); var API = Client.API; var Bitcore = require('bitcore'); var TestData = require('./clienttestdata'); var WalletUtils = require('../../lib/walletutils'); +var ExpressApp = require('../../lib/expressapp'); +var Storage = require('../../lib/storage'); + + +var helpers = {}; + +helpers.getRequest = function(app) { + return function(args, cb) { + var req = request(app); + var r = req[args.method](args.relUrl); + + if (args.headers) { + _.each(args.headers, function(v, k) { + r.set(k, v); + }) + } + if (!_.isEmpty(args.body)) { + r.send(args.body); + }; + r.end(function(err, res) { + return cb(err, res, res.body); + }); + }; +}; + +helpers.createAndJoinWallet = function(clients, m, n, cb) { + clients[0].createWallet('wallet name', 'creator', m, n, 'testnet', + function(err, secret) { + if (err) return cb(err); + if (n == 1) return cb(); + + should.exist(secret); + async.each(_.range(n - 1), function(i, cb) { + clients[i + 1].joinWallet(secret, 'copayer ' + (i + 1), function(err, result) { + should.not.exist(err); + return cb(err); + }); + }, function(err) { + if (err) return cb(err); + return cb(null, { + m: m, + n: n, + secret: secret, + }); + }); + }); +}; + + +var fsmock = {}; +var content = {}; +fsmock.readFile = function(name, enc, cb) { + if (!content || _.isEmpty(content[name])) + return cb('empty'); + + return cb(null, content[name]); +}; +fsmock.writeFile = function(name, data, cb) { + content[name] = data; + return cb(); +}; +fsmock.reset = function() { + content = {}; +}; + +fsmock._get = function(name) { + return content[name]; +}; + + +var blockExplorerMock = {}; +blockExplorerMock.utxos = []; + + + + +blockExplorerMock.getUnspentUtxos = function(dummy, cb) { + var ret = _.map(blockExplorerMock.utxos || [], function(x) { + x.toObject = function() { + return this; + }; + return x; + }); + return cb(null, ret); +}; + +blockExplorerMock.setUtxo = function(address, amount, m) { + blockExplorerMock.utxos.push({ + txid: Bitcore.crypto.Hash.sha256(new Buffer(Math.random() * 100000)).toString('hex'), + vout: Math.floor((Math.random() * 10) + 1), + amount: amount, + address: address.address, + scriptPubKey: Bitcore.Script.buildMultisigOut(address.publicKeys, m).toScriptHashOut().toString(), + }); +}; + + +blockExplorerMock.broadcast = function(raw, cb) { + blockExplorerMock.lastBroadcasted = raw; + return cb(null, (new Bitcore.Transaction(raw)).id); +}; + +blockExplorerMock.reset = function() { + blockExplorerMock.utxos = []; +}; describe('client API ', function() { - var client; + var clients, app; beforeEach(function() { - var fsmock = {};; - fsmock.readFile = sinon.mock().yields(null, JSON.stringify(TestData.storage.wallet11)); - fsmock.writeFile = sinon.mock().yields(); - var storage = new Client.FileStorage({ - filename: 'dummy', - fs: fsmock, + clients = []; + var db = levelup(memdown, { + valueEncoding: 'json' }); - client = new Client({ - storage: storage + var storage = new Storage({ + db: db }); - }); + app = ExpressApp.start({ + CopayServer: { + storage: storage, + blockExplorer: blockExplorerMock, + } + }); + // Generates 5 clients + _.each(_.range(5), function(i) { + var storage = new Client.FileStorage({ + filename: 'client' + i, + fs: fsmock, + }); + var client = new Client({ + storage: storage, + }); - describe('#_tryToComplete ', function() { - it('should complete a wallet ', function(done) { - var request = sinon.stub(); + client.request = helpers.getRequest(app); + clients.push(client); + }); + fsmock.reset(); + blockExplorerMock.reset(); + }); - // Wallet request - request.onCall(0).yields(null, { - statusCode: 200, - }, TestData.serverResponse.completeWallet); - request.onCall(1).yields(null, { - statusCode: 200, - }, "pepe"); + describe('Wallet Creation', function() { + it('should check balance in a 1-1 ', function(done) { + helpers.createAndJoinWallet(clients, 1, 1, function(err) { + should.not.exist(err); + clients[0].getBalance(function(err, x) { + should.not.exist(err); + done(); + }) + }); + }); + it('should be able to complete wallets in copayer that joined later', function(done) { + helpers.createAndJoinWallet(clients, 2, 3, function(err) { + should.not.exist(err); + clients[0].getBalance(function(err, x) { + should.not.exist(err); + clients[1].getBalance(function(err, x) { + should.not.exist(err); + clients[2].getBalance(function(err, x) { + should.not.exist(err); + done(); + }) + }) + }) + }); + }); - client.request = request; - client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); - client.getBalance(function(err, x) { + it('should not allow to join a full wallet ', function(done) { + helpers.createAndJoinWallet(clients, 2, 2, function(err, w) { should.not.exist(err); + should.exist(w.secret); + clients[4].joinWallet(w.secret, 'copayer', function(err, result) { + err.should.contain('Request error'); + done(); + }); + }); + }); + it('should fail with a unknown secret', function(done) { + var oldSecret = '3f8e5acb-ceeb-4aae-134f-692d934e3b1c:L2gohj8s2fLKqVU5cQutAVGciutUxczFxLxxXHFsjzLh71ZjkFQQ:T'; + clients[0].joinWallet(oldSecret, 'copayer', function(err, result) { + err.should.contain('Request error'); done(); }); }); + it('should reject wallets with bad signatures', function(done) { + helpers.createAndJoinWallet(clients, 2, 3, function(err) { + should.not.exist(err); + // Get right response + var data = clients[0]._load(function(err, data) { + var url = '/v1/wallets/'; + clients[0]._doGetRequest(url, data, function(err, x) { - it('should handle incomple wallets', function(done) { - var request = sinon.stub(); + // Tamper data + x.wallet.copayers[0].xPubKey = x.wallet.copayers[1].xPubKey; - // Wallet request - request.onCall(0).yields(null, { - statusCode: 200, - }, TestData.serverResponse.incompleteWallet); + // Tamper response + clients[1]._doGetRequest = sinon.stub().yields(null, x); - client.request = request; - client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); - client.createAddress(function(err, x) { - err.should.contain('Incomplete'); - done(); + clients[1].getBalance(function(err, x) { + err.should.contain('verified'); + done(); + }); + }); + }); }); }); - it('should reject wallets with bad signatures', function(done) { - var request = sinon.stub(); - // Wallet request - request.onCall(0).yields(null, { - statusCode: 200, - }, TestData.serverResponse.corruptWallet22); + it('should reject wallets with missing signatures', function(done) { + helpers.createAndJoinWallet(clients, 2, 3, function(err) { + should.not.exist(err); - client.request = request; - client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); - client.createAddress(function(err, x) { - err.should.contain('verified'); - done(); + // Get right response + var data = clients[0]._load(function(err, data) { + var url = '/v1/wallets/'; + clients[0]._doGetRequest(url, data, function(err, x) { + + // Tamper data + delete x.wallet.copayers[1].xPubKey; + + // Tamper response + clients[1]._doGetRequest = sinon.stub().yields(null, x); + + clients[1].getBalance(function(err, x) { + err.should.contain('verified'); + done(); + }); + }); + }); }); }); - it('should reject wallets with missing signatures ', function(done) { - var request = sinon.stub(); - // Wallet request - request.onCall(0).yields(null, { - statusCode: 200, - }, TestData.serverResponse.corruptWallet222); - client.request = request; - client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); - client.createAddress(function(err, x) { - err.should.contain('verified'); - done(); + it('should reject wallets missing caller"s pubkey', function(done) { + helpers.createAndJoinWallet(clients, 2, 3, function(err) { + should.not.exist(err); + + // Get right response + var data = clients[0]._load(function(err, data) { + var url = '/v1/wallets/'; + clients[0]._doGetRequest(url, data, function(err, x) { + + // Tamper data. Replace caller's pubkey + x.wallet.copayers[1].xPubKey = (new Bitcore.HDPrivateKey()).publicKey; + // Add a correct signature + x.wallet.copayers[1].xPubKeySignature = WalletUtils.signMessage( + x.wallet.copayers[1].xPubKey, data.walletPrivKey), + + // Tamper response + clients[1]._doGetRequest = sinon.stub().yields(null, x); + + clients[1].getBalance(function(err, x) { + err.should.contain('verified'); + done(); + }); + }); + }); }); }); + }); - it('should reject wallets missing caller"s pubkey', function(done) { - var request = sinon.stub(); - // Wallet request - request.onCall(0).yields(null, { - statusCode: 200, - }, TestData.serverResponse.missingMyPubKey); - client.request = request; - client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); - client.createAddress(function(err, x) { - err.should.contain('verified'); - done(); + describe('Address Creation', function() { + it('should be able to create address in all copayers in a 2-3 wallet', function(done) { + this.timeout(5000); + helpers.createAndJoinWallet(clients, 2, 3, function(err) { + should.not.exist(err); + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + clients[1].createAddress(function(err, x1) { + should.not.exist(err); + should.exist(x1.address); + clients[2].createAddress(function(err, x2) { + should.not.exist(err); + should.exist(x2.address); + done(); + }); + }); + }); + }); + }); + it('should see balance on address created by others', function(done) { + helpers.createAndJoinWallet(clients, 2, 2, function(err, w) { + should.not.exist(err); + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + + blockExplorerMock.setUtxo(x0, 10, w.m); + clients[0].getBalance(function(err, bal0) { + should.not.exist(err); + bal0.totalAmount.should.equal(10 * 1e8); + bal0.lockedAmount.should.equal(0); + clients[1].getBalance(function(err, bal1) { + bal1.totalAmount.should.equal(10 * 1e8); + bal1.lockedAmount.should.equal(0); + done(); + }); + }); + }); }); }); }); - describe('#createAddress ', function() { - it('should check address ', function(done) { - var response = { - createdOn: 1424105995, - address: '2N3fA6wDtnebzywPkGuNK9KkFaEzgbPRRTq', - path: 'm/2147483647/0/7', - publicKeys: ['03f6a5fe8db51bfbaf26ece22a3e3bc242891a47d3048fc70bc0e8c03a071ad76f'] - }; - var request = sinon.mock().yields(null, { - statusCode: 200 - }, response); - client.request = request; + describe('Wallet Backups and Mobility', function() { + it('round trip #import #export', function(done) { + helpers.createAndJoinWallet(clients, 2, 2, function(err, w) { + should.not.exist(err); + clients[0].export(function(err, str) { + should.not.exist(err); + var original = JSON.parse(fsmock._get('client0')); + clients[2].import(str, function(err, wallet) { + should.not.exist(err); + var clone = JSON.parse(fsmock._get('client2')); + delete original.walletPrivKey; // no need to persist it. + clone.should.deep.equal(original); + done(); + }); - client.createAddress(function(err, x) { + }); + }); + }); + it('should recreate a wallet, create addresses and receive money', function(done) { + var backup = '["tprv8ZgxMBicQKsPehCdj4HM1MZbKVXBFt5Dj9nQ44M99EdmdiUfGtQBDTSZsKmzdUrB1vEuP6ipuoa39UXwPS2CvnjE1erk5aUjc5vQZkWvH4B",2,["tpubD6NzVbkrYhZ4XCNDPDtyRWPxvJzvTkvUE2cMPB8jcUr9Dkicv6cYQmA18DBAid6eRK1BGCU9nzgxxVdQUGLYJ34XsPXPW4bxnH4PH6oQBF3"],"sd0kzXmlXBgTGHrKaBW4aA=="]'; + clients[0].import(backup, function(err, wallet) { should.not.exist(err); - x.address.should.equal('2N3fA6wDtnebzywPkGuNK9KkFaEzgbPRRTq'); - done(); + clients[0].reCreateWallet('pepe', function(err, wallet) { + should.not.exist(err); + + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + blockExplorerMock.setUtxo(x0, 10, 2); + clients[0].getBalance(function(err, bal0) { + should.not.exist(err); + bal0.totalAmount.should.equal(10 * 1e8); + bal0.lockedAmount.should.equal(0); + done(); + }); + }); + }); + }); + }); + }); + + + describe('Send Transactions', function() { + it('Send and broadcast in 1-1 wallet', function(done) { + helpers.createAndJoinWallet(clients, 1, 1, function(err, w) { + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + blockExplorerMock.setUtxo(x0, 1, 1); + var opts = { + amount: '0.1btc', + toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', + message: 'hola 1-1', + }; + clients[0].sendTxProposal(opts, function(err, x) { + should.not.exist(err); + x.requiredRejections.should.equal(1); + x.requiredSignatures.should.equal(1); + x.status.should.equal('pending'); + x.changeAddress.path.should.equal('m/2147483647/1/0'); + clients[0].signTxProposal(x, function(err, tx) { + should.not.exist(err); + tx.status.should.equal('broadcasted'); + tx.txid.should.equal((new Bitcore.Transaction(blockExplorerMock.lastBroadcasted)).id); + done(); + }); + }); + }); }); }); + it('Send and broadcast in 2-3 wallet', function(done) { + helpers.createAndJoinWallet(clients, 2, 3, function(err, w) { + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + blockExplorerMock.setUtxo(x0, 10, 1); + var opts = { + amount: 10000, + toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', + message: 'hola 1-1', + }; + clients[0].sendTxProposal(opts, function(err, x) { + should.not.exist(err); + x.status.should.equal('pending'); + x.requiredRejections.should.equal(2); + x.requiredSignatures.should.equal(2); + clients[0].signTxProposal(x, function(err, tx) { + should.not.exist(err, err); + tx.status.should.equal('pending'); + clients[1].signTxProposal(x, function(err, tx) { + should.not.exist(err); + tx.status.should.equal('broadcasted'); + tx.txid.should.equal((new Bitcore.Transaction(blockExplorerMock.lastBroadcasted)).id); + done(); + }); + }); + }); + }); + }); + }); + + it('Send, reject, 2 signs and broadcast in 2-3 wallet', function(done) { + helpers.createAndJoinWallet(clients, 2, 3, function(err, w) { + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + blockExplorerMock.setUtxo(x0, 10, 1); + var opts = { + amount: 10000, + toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', + message: 'hola 1-1', + }; + clients[0].sendTxProposal(opts, function(err, x) { + should.not.exist(err); + x.status.should.equal('pending'); + x.requiredRejections.should.equal(2); + x.requiredSignatures.should.equal(2); + clients[0].rejectTxProposal(x, 'no me gusto', function(err, tx) { + should.not.exist(err, err); + tx.status.should.equal('pending'); + clients[1].signTxProposal(x, function(err, tx) { + should.not.exist(err); + clients[2].signTxProposal(x, function(err, tx) { + should.not.exist(err); + tx.status.should.equal('broadcasted'); + tx.txid.should.equal((new Bitcore.Transaction(blockExplorerMock.lastBroadcasted)).id); + done(); + }); + }); + }); + }); + }); + }); + }); + + it('Send, reject in 3-4 wallet', function(done) { + helpers.createAndJoinWallet(clients, 3, 4, function(err, w) { + clients[0].createAddress(function(err, x0) { + should.not.exist(err); + should.exist(x0.address); + blockExplorerMock.setUtxo(x0, 10, 1); + var opts = { + amount: 10000, + toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', + message: 'hola 1-1', + }; + clients[0].sendTxProposal(opts, function(err, x) { + should.not.exist(err); + x.status.should.equal('pending'); + x.requiredRejections.should.equal(2); + x.requiredSignatures.should.equal(3); + + clients[0].rejectTxProposal(x, 'no me gusto', function(err, tx) { + should.not.exist(err, err); + tx.status.should.equal('pending'); + clients[1].signTxProposal(x, function(err, tx) { + should.not.exist(err); + tx.status.should.equal('pending'); + clients[2].rejectTxProposal(x, 'tampoco me gusto', function(err, tx) { + should.not.exist(err); + tx.status.should.equal('rejected'); + done(); + }); + }); + }); + }); + }); + }); + }); + + }); + + + /* + describe('TODO', function(x) { it('should detect fake addresses ', function(done) { var response = { createdOn: 1424105995, @@ -151,25 +515,6 @@ describe('client API ', function() { }); - describe('#export & #import 2-2 wallet', function() { - it('round trip ', function(done) { - client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.complete22)); - client.export(function(err, str) { - should.not.exist(err); - - client.storage.fs.readFile = sinon.stub().yields(null); - client.import(str, function(err, wallet) { - should.not.exist(err); - var wallet = JSON.parse(client.storage.fs.writeFile.getCall(0).args[1]); - TestData.storage.complete22.should.deep.equal(wallet); - - done(); - }); - }); - }); - }); - - describe('#getTxProposals', function() { it('should return tx proposals and decrypt message', function(done) { client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.complete11)); @@ -188,10 +533,6 @@ describe('client API ', function() { }); }); - describe('#recreate', function() { - it.skip('Should recreate a wallet acording stored data', function(done) {}); - }); - describe('#sendTxProposal ', function() { it('should send tx proposal with encrypted message', function(done) { client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.complete11)); @@ -261,4 +602,5 @@ describe('client API ', function() { }); }); }); + */ }); diff --git a/test/integration/server.js b/test/integration/server.js index 92490db..cf5e799 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -20,7 +20,6 @@ var Copayer = require('../../lib/model/copayer'); var CopayServer = require('../../lib/server'); var TestData = require('../testdata'); - var helpers = {}; helpers.getAuthServer = function(copayerId, cb) { var signatureStub = sinon.stub(CopayServer.prototype, '_verifySignature');