diff --git a/ABOUT.md b/ABOUT.md index dbb130b..b871c98 100644 --- a/ABOUT.md +++ b/ABOUT.md @@ -31,13 +31,6 @@ realtime resource server Consult the [documentation](http://deployd.github.com/deployd) or contact `ritchie at deployd com`. -## changelog - -### 0.5 - - - removed `property.optional` in favor of `property.required` - - changed `object._id` to `object.id` on all stored objects - ## license Copyright 2012 deployd, llc diff --git a/HISTORY.md b/HISTORY.md index 4ec4338..2cb1fe0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,9 +4,16 @@ - Added new data editor - Fixed major bug where calling error() would not always cancel the request - Fixed bug where PUT would fail without an error if you provided a query + - Changed root behavior - no longer ignores cancel() in events + - Fixed bugs preventing events from being `emit()`ed to users in certain connection states + - Fixed bug where boolean query values (?bool=true) were not treated as booleans + - Fixed unnecessary error when parsing JSON body + - Added more intelegent body parsing + ## 0.6.6 + - Added CORS support - Exposed the server object to modules as `process.server` - Fixed a rare bug where the first request after a login would not be authenticated - Fixed minor bug when loading only node modules diff --git a/bin/dpd b/bin/dpd index 8ffffdd..1d433dd 100755 --- a/bin/dpd +++ b/bin/dpd @@ -33,7 +33,7 @@ program .option('-d, --dashboard', 'start the dashboard immediately') .option('-o, --open', 'open in a browser') .option('-e, --environment [env]', 'defaults to development') - .option('-h, --host [host]', 'specify host for mongo server') + .option('-H, --host [host]', 'specify host for mongo server') .option('-P, --mongoPort [mongoPort]', 'mongodb port to connect to') .option('-n, --dbname [dbname]', 'name of the mongo database') .option('-a, --auth', 'prompts for mongo server credentials') diff --git a/docs/about.md b/docs/about.md index de42047..517f343 100644 --- a/docs/about.md +++ b/docs/about.md @@ -1,4 +1,4 @@ -# About Deployd Server +# About Deployd We call Deployd a **resource server**. A resource server is not a library, but a complete server that works out of the box, and can be customized to fit the needs of your app by adding resources. Resources are ready-made components that live at a URL and provide functionality to your client app. diff --git a/docs/deploy.md b/docs/deploy.md index 461a3e5..5c5ef3e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -26,7 +26,7 @@ To provide additional collaborators access to push new versions and access the d You can also deploy your app on your server, or on a cloud hosting service such as EC2 or Heroku. The server must support [Node.js](http://nodejs.org/). -Deployd also requires a [MongoDB] (http://www.mongodb.org/) database, which can be hosted on the same server or externally. +Deployd also requires a [MongoDB](http://www.mongodb.org/) database, which can be hosted on the same server or externally. If you have root shell access on the deployment server, you can install Deployd on it using the command `npm install -g deployd`. Otherwise, you will need to install Deployd as a dependency of your app itself using `npm install deployd` in the root directory of your app. diff --git a/docs/module-api/internal-client.md b/docs/module-api/internal-client.md index c52853a..d28428d 100644 --- a/docs/module-api/internal-client.md +++ b/docs/module-api/internal-client.md @@ -6,8 +6,8 @@ The `internal-client` module is responsible for building a server-side version o ## internalClient.build(server, [session], [stack]) - var internalClient = require('deployd/lib/internalClient'); - var dpd = internalClient.build(server, req.session, req.stack); + var internalClient = require('deployd/lib/internal-client'); + var dpd = internalClient.build(server); dpd.todos.get(function(data, err) { // Do something... diff --git a/docs/module-api/script.md b/docs/module-api/script.md index c4c07b1..8912b77 100644 --- a/docs/module-api/script.md +++ b/docs/module-api/script.md @@ -61,7 +61,7 @@ If a callback is provided the script will be run in **async mode**. The callback * fn(err, script) -Load a new `script` at the given file `path`. Callback with an error if one occured or a new `Script` loaded from the contents of the file. +Load a new `script` at the given file `path`. Callback with an error if one occured or a new `Script` loaded from the contents of the file. ## Default Domain @@ -71,8 +71,6 @@ Scripts are executed with a default sandbox and set of domain functions. These a Throws an error that immediately stops the execution of a context and calls the callback passed to `script.run()` passing the error as the first argument. -`cancel()` does not have an effect if the current `Context.isRoot` or `Context.internal` is true. - ### emit([collection], [query], event, data) Stability: will change in 0.7 @@ -85,5 +83,7 @@ The default sandbox or global object in a `Script` comes with several other prop - `me` - the current user if one exists on the `Context` - `this` - an empty object if not overridden by the `domain` + - `internal` - a boolean property, true if this request has been initiated by another script + - `isRoot` - a boolean property, true if this request is authenticated as root (from the dashboard or a custom script) - `query` - the current `Context`'s query - `console` - support for `console.log()` and other `console` methods diff --git a/docs/reference/collection-events.md b/docs/reference/collection-events.md index a69ad8a..033d24c 100644 --- a/docs/reference/collection-events.md +++ b/docs/reference/collection-events.md @@ -161,6 +161,29 @@ Dpd.js will prevent recursive queries. This works by returning `null` from a `dp ] } + +### internal + +Equal to true if this request has been sent by another script. + + // Example: On GET /posts + // Posts with a parent are invisible, but are counted by their parent + if (this.parentId && !internal) cancel(); + + dpd.posts.get({parentId: this.id}, function(posts) { + this.childPosts = posts.length; + }); + +### isRoot + +Equal to true if this request has been authenticated as root (has the `dpd-ssh-key` header with the appropriate key) + + // Example: On PUT /users + // Protect reputation property - should only be calculated by a custom script. + + if (!isRoot) protect('reputation'); + + ### console.log() console.log([arguments]...) diff --git a/lib/resources/collection/dashboard/js/data.js b/lib/resources/collection/dashboard/js/data.js index 181ecc6..b4c3b2d 100644 --- a/lib/resources/collection/dashboard/js/data.js +++ b/lib/resources/collection/dashboard/js/data.js @@ -213,6 +213,11 @@ return true; }; + vm.edit.isEditableModal = function() { + var type = vm.selectedProp().type; + return vm.edit.isJson() || type === 'string'; + }; + vm.edit.hasChanged = ko.observable(false); vm.edit.editValue.subscribe(function(newVal) { vm.edit.hasChanged(true); @@ -418,7 +423,7 @@ rowVm._deleteRow = function() { if (rowVm.id()) { var index = vm.data.indexOf(rowVm); - dpd(resource).del(rowVm.id(), function(res, err) { + dpd(resource).del(rowVm.id(), {$skipEvents: true}, function(res, err) { if (err) return showError("Error deleting row", err); vm.data.remove(rowVm); var data = vm.data(); @@ -462,15 +467,23 @@ rowVm[name](value); if (rowVm.id()) { - if (!options.dontNotify) { - createUndo("Changed " + vm.selectedProp().name, function() { - rowVm._saveProp(prop, lastValue, {dontNotify: true}); - vm.selectedProp(prop); - vm.selectedRow(rowVm); - }); - } + body[name] = value; - dpd(resource).put(rowVm.id(), body); + body.$skipEvents = true; + dpd(resource).put(rowVm.id(), body, function(res, err) { + if (err) { + showError("Error updating row", err); + rowVm[name](lastValue); + } + if (!options.dontNotify) { + createUndo("Changed " + vm.selectedProp().name, function() { + rowVm._saveProp(prop, lastValue, {dontNotify: true}); + vm.selectedProp(prop); + vm.selectedRow(rowVm); + }); + } + + }); } else if (!options.dontSave) { rowVm._save(); } @@ -516,6 +529,7 @@ } function postRow(data, fn) { + data.$skipEvents = true; dpd(resource).post(data, function(res, err) { if (err) return fn(null, err); vm.fadeInRows.push(getRowById(res.id)); //In case it's already there @@ -647,7 +661,7 @@ return false; case 13: //enter - if (e.ctrlKey) { + if (e.ctrlKey && vm.edit.isEditableModal()) { vm.selectedRow()._editProp(vm.selectedProp(), {modal: true}); } else { vm.selectedRow()._editProp(vm.selectedProp()); @@ -793,7 +807,7 @@ var begin = page - 1; begin = Math.max(0, Math.min(begin, getLastPage() - 1)); - dpd(resource).get({$skip: begin*PAGE_SIZE, $limit: PAGE_SIZE*3}, function(res, err) { + dpd(resource).get({$skip: begin*PAGE_SIZE, $limit: PAGE_SIZE*3, $skipEvents: true}, function(res, err) { if (err) return; vm.currentPage(page); ko.mapping.fromJS({data: res}, rowMapping, vm); diff --git a/lib/resources/collection/dashboard/js/util/knockout-util.js b/lib/resources/collection/dashboard/js/util/knockout-util.js index cdf89ad..40c0357 100644 --- a/lib/resources/collection/dashboard/js/util/knockout-util.js +++ b/lib/resources/collection/dashboard/js/util/knockout-util.js @@ -400,7 +400,11 @@ ko.bindingHandlers.aceEditorOptions = { ko.bindingHandlers.typeahead = { update: function(element, valueAccessor) { var value = ko.utils.unwrapObservable(valueAccessor()); - $(element).typeahead({source: value}); + if (window.typeahead) { + $(element).typeahead({source: value}); + } else { + $(element).typeahead({source: []}); + } } }; diff --git a/lib/resources/collection/index.js b/lib/resources/collection/index.js index 8af0b2a..1f24227 100644 --- a/lib/resources/collection/index.js +++ b/lib/resources/collection/index.js @@ -143,16 +143,19 @@ Collection.prototype.sanitizeQuery = function (query) { // skip properties that do not exist, but allow $ queries and id if(!prop && key.indexOf('$') !== 0 && key !== 'id') return; - // hack - $limitRecursion is not a mongo property so we'll get rid of it, too + // hack - $limitRecursion and $skipEvents are not mongo properties so we'll get rid of them, too if (key === '$limitRecursion') return; + if (key === '$skipEvents') return; if(expected == 'number' && actual == 'string') { sanitized[key] = parseFloat(val); + } else if(expected == 'boolean' && actual != 'boolean') { + sanitized[key] = (val === 'true') ? true : false; } else if (typeof val !== 'undefined') { sanitized[key] = val; } }); - + return sanitized; }; @@ -316,9 +319,7 @@ Collection.prototype.find = function (ctx, fn) { errors[key] = val || true; }, hide: function(property) { - if (!session.isRoot) { - delete data[property]; - } + delete data[property]; }, 'this': data, data: data @@ -350,9 +351,7 @@ Collection.prototype.find = function (ctx, fn) { errors[key] = val || true; }, hide: function(property) { - if (!session.isRoot) { - delete data[property]; - } + delete data[property]; }, 'this': data, data: data @@ -381,17 +380,18 @@ Collection.prototype.remove = function (ctx, fn) { , store = this.store , session = ctx.session , query = ctx.query + , sanitizedQuery = this.sanitizeQuery(query) , errors; if(!(query && query.id)) return fn('You must include a query with an id when deleting an object from a collection.'); - store.find(query, function (err, result) { + store.find(sanitizedQuery, function (err, result) { if(err) { return fn(err); } function done(err) { if(err) return fn(err); - store.remove(query, fn); + store.remove(sanitizedQuery, fn); if(session.emitToAll) session.emitToAll(collection.name + ':changed'); } @@ -424,6 +424,7 @@ Collection.prototype.save = function (ctx, fn) { , store = this.store , session = ctx.session , item = ctx.body + , query = ctx.query || {} , client = ctx.dpd , errors; @@ -453,6 +454,7 @@ Collection.prototype.save = function (ctx, fn) { function done(err, item) { errors = errors && {errors: errors}; + debug('errors: %j', err); fn(errors || err, item); } @@ -463,24 +465,21 @@ Collection.prototype.save = function (ctx, fn) { errors[key] = val || true; }, hide: function(property) { - if (!session.isRoot) { - delete item[property]; - } + delete item[property]; }, protect: function(property) { - if (!session.isRoot) { - delete item[property]; - } + delete item[property]; }, 'this': item, data: item }; function put() { - var id = query.id; - store.first(query, function(err, obj) { + var id = query.id + , sanitizedQuery = collection.sanitizeQuery(query); + store.first(sanitizedQuery, function(err, obj) { if(!obj) { - if (Object.keys(query) === 1) { + if (Object.keys(sanitizedQuery) === 1) { return done(new Error("No object exists with that id")); } else { return done(new Error("No object exists that matches that query")); @@ -671,8 +670,8 @@ Collection.prototype.execCommands = function (type, obj, commands) { }; Collection.prototype.shouldRunEvent = function(ev, ctx) { - var runEvents = ctx && ((ctx.body && ctx.body.$runEvents) || (ctx.query && ctx.query.$runEevents)) - , rootPrevent = ctx && ctx.session && ctx.session.isRoot && !runEvents; + var skipEvents = ctx && ((ctx.body && ctx.body.$skipEvents) || (ctx.query && ctx.query.$skipEvents)) + , rootPrevent = ctx && ctx.session && ctx.session.isRoot && skipEvents; return !rootPrevent && ev; }; diff --git a/lib/script.js b/lib/script.js index f52a260..4140edb 100644 --- a/lib/script.js +++ b/lib/script.js @@ -37,15 +37,14 @@ Script.prototype.run = function (ctx, domain, fn) { var scriptContext = { 'this': {}, cancel: function(msg, status) { - if (!req.isRoot) { - var err = {message: msg, statusCode: status}; - throw err; - } + var err = {message: msg, statusCode: status}; + throw err; }, me: session && session.user, console: console, query: ctx.query, internal: req && req.internal, + isRoot: req && req.session && req.session.isRoot, emit: function(collection, query, event, data) { if(arguments.length === 4) { session.emitToUsers(collection, query, event, data); diff --git a/lib/session.js b/lib/session.js index 3f829e8..70f76fa 100644 --- a/lib/session.js +++ b/lib/session.js @@ -25,19 +25,20 @@ function SessionStore(namespace, db, sockets) { var socketQueue = this.socketQueue = new EventEmitter() , socketIndex = this.socketIndex = {}; - // TODO - sockets.on('connection', ...) - map to a session id based on socket.handshake.headers - sockets && sockets.on('connection', function (socket) { - // NOTE: do not use set here ever, the `Cookies` api is meant to get a req, res - // but we are just using it for a cookie parser - var cookies = new Cookies(socket.handshake) - , sid = cookies.get('sid'); + if(sockets) { + sockets.on('connection', function (socket) { + // NOTE: do not use set here ever, the `Cookies` api is meant to get a req, res + // but we are just using it for a cookie parser + var cookies = new Cookies(socket.handshake) + , sid = cookies.get('sid'); - if(sid) { - // index sockets against their session id - socketIndex[sid] = socket; - socketQueue.emit(sid, socket); - } - }); + if(sid) { + // index sockets against their session id + socketIndex[sid] = socket; + socketQueue.emit(sid, socket); + } + }); + } Store.apply(this, arguments); } @@ -105,43 +106,52 @@ SessionStore.prototype.createSession = function(sid, fn) { */ function Session(data, store, sockets, rawSockets) { +<<<<<<< HEAD var sess = this; +======= + var sid; +>>>>>>> 929285c8b89118aa505a218b939cec34d431e38e this.data = data; - if(data && data.id) this.sid = data.id; + if(data && data.id) this.sid = sid = data.id; this.store = store; // create faux socket, to queue any events until // a real socket is available var socketWrapper = this.socket = { on: function () { + var s = sockets[sid]; // if we have a real socket, use it - if(this._socket) { - this._socket.apply(this._socket, arguments); + if(s) { + s.on.apply(s, arguments); } else { // otherwise add to bind queue var queue = this._bindQueue = this._bindQueue || []; queue.push(arguments); } }, - emit: function () { + emit: function (ev) { + var s = sockets[sid]; + // if we have a real socket, use it - if(this._socket) { - this._socket.emit.apply(this._socket, arguments); + if(s) { + s.emit.apply(s, arguments); } else { // otherwise add to bind queue var queue = this._emitQueue = this._bindQueue || []; queue.push(arguments); } - }, - _socket: sockets[this.sid] + } }; this.emitToUsers = function(collection, query, event, data) { collection.get(query, function(users) { var userSession; - // TODO: arguments in weird order if(users && users.id) { +<<<<<<< HEAD userSession = userSessionIndex[err.id]; +======= + userSession = userSessionIndex[users.id]; +>>>>>>> 929285c8b89118aa505a218b939cec34d431e38e if(userSession && userSession.socket) { userSession.socket.emit(event, data); } @@ -169,7 +179,6 @@ function Session(data, store, sockets, rawSockets) { sess.set({host: add + ':' + process.server.options.port}).save(); }); - socketWrapper._socket = socket; // drain bind queue if(socketWrapper._bindQueue && socketWrapper._bindQueue.length) { socketWrapper._bindQueue.forEach(function (args) { diff --git a/lib/util/http.js b/lib/util/http.js index d2964a6..a1a9c98 100644 --- a/lib/util/http.js +++ b/lib/util/http.js @@ -14,33 +14,41 @@ exports.setup = function(req, res, next) { , handler = corser.create({supportsCredentials: true, methods: ALLOWED_METHODS, origins: origins}); handler(req, res, function () { - if (req.method === "OPTIONS") { - // End CORS preflight request. - res.writeHead(204); - res.end(); - } else { - var mime = req.headers['content-type'] || ''; - mime = mime.split(';')[0]; //Just in case there's multiple mime types, pick the first + req.cookies = res.cookies = new Cookies(req, res); + + if(~req.url.indexOf('?')) { + try { + req.query = parseQuery(req.url); + } catch (ex) { + res.setHeader('Content-Type', 'text/plain'); + res.statusCode = 400; + res.end('Failed to parse querystring: ' + ex); + return; + } + } + + switch(req.method) { + case 'OPTIONS': + // End CORS preflight request. + res.writeHead(204); + res.end(); + break; + case 'POST': + case 'PUT': + case 'DELETE': + var mime = req.headers['content-type'] || 'application/json'; + mime = mime.split(';')[0]; //Just in case there's multiple mime types, pick the first - req.cookies = res.cookies = new Cookies(req, res); - - if(~req.url.indexOf('?')) { - try { - req.query = parseQuery(req.url); - } catch (ex) { - res.setHeader('Content-Type', 'text/plain'); - res.statusCode = 400; - res.end('Failed to parse querystring: ' + ex); - return; + if(autoParse[mime]) { + autoParse[mime](req, res, mime, next); + } else { + if(req.headers['content-length']) req.pause(); + next(); } - } - - if(autoParse[mime]) { - autoParse[mime](req, res, mime, next); - } else { - if(req.headers['content-length']) req.pause(); + break; + default: next(); - } + break; } }); }; @@ -68,7 +76,18 @@ var parseBody = exports.parseBody = function(req, res, mime, callback) { } try { - req.body = parser.parse(buf); + if(buf.length) { + if(mime === 'application/json' && '{' != buf[0] && '[' != buf[0]) { + res.setHeader('Content-Type', 'text/plain'); + res.statusCode = 400; + res.end('Could not parse invalid JSON'); + return; + } + + req.body = parser.parse(buf); + } else { + req.body = {}; + } callback(); } catch (ex) { res.setHeader('Content-Type', 'text/plain'); diff --git a/test-app/public/index.html b/test-app/public/index.html index 188d10d..bf18e6f 100644 --- a/test-app/public/index.html +++ b/test-app/public/index.html @@ -5,6 +5,7 @@ Mocha Tests + diff --git a/test-app/public/test/collection.test.js b/test-app/public/test/collection.test.js index 79142a8..e36a383 100644 --- a/test-app/public/test/collection.test.js +++ b/test-app/public/test/collection.test.js @@ -1,3 +1,4 @@ +/*global _dpd:false */ describe('Collection', function() { describe('dpd.todos', function() { it('should exist', function() { @@ -67,6 +68,18 @@ describe('Collection', function() { done(); }); }); + it('should create a todo that exists in the store', function(done) { + dpd.todos.post({title: 'faux'}, function (todo, err) { + expect(todo.id.length).to.equal(16); + expect(todo.title).to.equal('faux'); + expect(err).to.not.exist; + dpd.todos.get(todo.id, function(res, err) { + if (err) return done(err); + expect(res.title).to.equal('faux'); + done(); + }); + }); + }); }); describe('.post({title: "notvalid"}, fn)', function() { @@ -211,6 +224,29 @@ describe('Collection', function() { }); }); }); + + describe('GET /full?boolean=true', function () { + it('should filter boolean properties by query string', function(done) { + dpd.full.post({boolean: true}, function (full) { + dpd.full.post({boolean: false}, function(full){ + $.ajax({ + type: "GET", + url: "/full?boolean=true", + success: function (res) { + expect(res.length).to.be.greaterThan(0); + res.forEach(function(obj){ + expect(obj.boolean).to.equal(true); + }); + done(); + }, + error: function (e) { + done(e); + } + }); + }); + }); + }); + }); describe('.get({id: "non existent"}, fn)', function() { it('should return a 404', function(done) { @@ -381,7 +417,6 @@ describe('Collection', function() { todoId = res.id; dpd.todos.put(todoId, {message: "notvalidput"}, next); }).chain(function(next, res, err) { - console.log(res, err); expect(err).to.exist; expect(err.errors).to.exist; expect(err.errors.message).to.equal("message should not be notvalidput"); @@ -592,6 +627,77 @@ describe('Collection', function() { }); }); + describe('root', function() { + afterEach(function(done) { + _dpd.ajax.headers = {}; + cleanCollection(dpd.todos, done); + }); + + describe('dpd-ssh-key', function() { + beforeEach(function() { + _dpd.ajax.headers = { + 'dpd-ssh-key': true + }; + }); + + it('should detect root', function(done) { + chain(function(next) { + dpd.todos.post({title: 'valid'}, next); + }).chain(function(next, res, err) { + if (err) return done(err); + expect(res.isRoot).to.equal(true); + done(); + }); + }); + + it('should allow skipping events', function(done) { + chain(function(next) { + dpd.todos.post({title: 'notvalid', $skipEvents: true}, next); + }).chain(function(next, res, err) { + if (err) return done(err); + expect(res.title).to.equal('notvalid'); + done(); + }); + }); + + it('should allow skipping events on get', function(done) { + var id; + chain(function(next) { + dpd.todos.post({title: '$GET_CANCEL'}, next); + }).chain(function(next, res, err) { + if (err) return done(err); + id = res.id; + dpd.todos.get(id, {$skipEvents: true}, next); + }).chain(function(next, res, err) { + if (err) return done(err); + expect(res.title).to.equal("$GET_CANCEL"); + done(); + }); + }); + }); + + it('should not allow skipping events', function(done) { + chain(function(next) { + dpd.todos.post({title: 'notvalid', $skipEvents: true}, next); + }).chain(function(next, res, err) { + expect(err).to.exist; + expect(err.errors).to.exist; + done(); + }); + }); + + it('should not detect root', function(done) { + chain(function(next) { + dpd.todos.post({title: 'valid'}, next); + }).chain(function(next, res, err) { + if (err) return done(err); + expect(res.isRoot).to.not.exist; + done(); + }); + }); + + }); + describe('dpd.recursive', function() { beforeEach(function(done) { dpd.recursive.post({name: "dataception"}, function(res) { diff --git a/test-app/resources/todos/post.js b/test-app/resources/todos/post.js index d3db4f6..f864703 100644 --- a/test-app/resources/todos/post.js +++ b/test-app/resources/todos/post.js @@ -35,4 +35,8 @@ if (this.title === "$CANCEL_TEST") { if (this.title === "$INTERNAL_CANCEL_TEST") { if (!internal) cancel('internal cancel'); +} + +if (isRoot) { + this.isRoot = true; } \ No newline at end of file diff --git a/test/sessions.unit.js b/test/sessions.unit.js index 0dcb264..9c9ec36 100644 --- a/test/sessions.unit.js +++ b/test/sessions.unit.js @@ -47,19 +47,6 @@ describe('Session', function() { }); } - - it('should make the socket available from the session', function(done) { - var sockets = new EventEmitter() - , store = new SessionStore('sessions', db.create(TEST_DB), sockets); - - store.createSession(function (err, session) { - var fauxSocket = {handshake: { headers: {cookie: 'name=value; name2=value2; sid=' + session.sid} } }; - sockets.emit('connection', fauxSocket); - expect(session.socket._socket).to.equal(fauxSocket); - done(err); - }); - }); - it('should make sockets available even before they exist', function(done) { this.timeout(100); diff --git a/test/util.unit.js b/test/util.unit.js index 8898dc6..bcbc44a 100644 --- a/test/util.unit.js +++ b/test/util.unit.js @@ -83,6 +83,17 @@ describe('.parseBody()', function() { req.emit('data', value); req.emit('end'); }); + + it('should interpret an empty body as an empty object', function(done) { + var req = new Stream(); + + http.parseBody(req, this.res, 'application/json', function(err) { + expect(err).to.not.exist; + expect(req.body).to.eql({}); + done(); + }); + req.emit('end'); + }); }); });