sharedb 6.0.5 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agent.js CHANGED
@@ -25,8 +25,7 @@ function Agent(backend, stream) {
25
25
  // src is a client-configurable "id" which the client will set in its handshake,
26
26
  // and attach to its ops. This should take precedence over clientId if set.
27
27
  // Only legacy clients, or new clients connecting for the first time will use the
28
- // Agent-provided clientId. Ideally we'll deprecate clientId in favour of src
29
- // in the next breaking change.
28
+ // Agent-provided clientId.
30
29
  this.src = null;
31
30
  this.connectTime = Date.now();
32
31
 
@@ -382,6 +381,42 @@ Agent.prototype._open = function() {
382
381
  this.stream.on('close', cleanup);
383
382
  };
384
383
 
384
+ function isInvalidName(name) {
385
+ return typeof name !== 'string' || util.isDangerousProperty(name);
386
+ }
387
+
388
+ function isInvalidVersion(version) {
389
+ return version != null && (typeof version !== 'number' || version < 0);
390
+ }
391
+
392
+ function isInvalidProtocol(value) {
393
+ return value != null && typeof value !== 'number' && typeof value !== 'string';
394
+ }
395
+
396
+ function checkBulkData(data) {
397
+ if (!data || typeof data !== 'object') return 'Invalid bulk subscribe data';
398
+ if (Array.isArray(data)) {
399
+ if (data.some(isInvalidName)) return 'Invalid id';
400
+ return;
401
+ }
402
+ for (var id in data) {
403
+ if (isInvalidName(id)) return 'Invalid id';
404
+ if (isInvalidVersion(data[id])) return 'Invalid version';
405
+ }
406
+ }
407
+
408
+ function checkQueryResults(results) {
409
+ if (!Array.isArray(results)) return 'Invalid query results';
410
+ for (var i = 0; i < results.length; i++) {
411
+ var result = results[i];
412
+ if (!Array.isArray(result)) return 'Invalid query results';
413
+ var id = result[0];
414
+ var version = result[1];
415
+ if (isInvalidName(id)) return 'Invalid id';
416
+ if (isInvalidVersion(version)) return 'Invalid version';
417
+ }
418
+ }
419
+
385
420
  // Check a request to see if its valid. Returns an error if there's a problem.
386
421
  Agent.prototype._checkRequest = function(request) {
387
422
  if (
@@ -391,6 +426,10 @@ Agent.prototype._checkRequest = function(request) {
391
426
  ) {
392
427
  // Query messages need an ID property.
393
428
  if (typeof request.id !== 'number') return 'Missing query ID';
429
+ if (request.r != null) {
430
+ var resultsError = checkQueryResults(request.r);
431
+ if (resultsError) return resultsError;
432
+ }
394
433
  } else if (request.a === ACTIONS.op ||
395
434
  request.a === ACTIONS.fetch ||
396
435
  request.a === ACTIONS.subscribe ||
@@ -399,30 +438,19 @@ Agent.prototype._checkRequest = function(request) {
399
438
  request.a === ACTIONS.snapshotFetchByTimestamp ||
400
439
  request.a === ACTIONS.presence) {
401
440
  // Doc-based request.
402
- if (request.c != null) {
403
- if (typeof request.c !== 'string' || util.isDangerousProperty(request.c)) {
404
- return 'Invalid collection';
405
- }
406
- }
407
- if (request.d != null) {
408
- if (typeof request.d !== 'string' || util.isDangerousProperty(request.d)) {
409
- return 'Invalid id';
410
- }
411
- }
441
+ if (request.d != null && isInvalidName(request.d)) return 'Invalid id';
412
442
 
413
443
  if (request.a === ACTIONS.snapshotFetch || request.a === ACTIONS.snapshotFetchByTimestamp) {
414
444
  if (request.c == null) return 'Invalid collection';
415
445
  if (request.d == null) return 'Invalid id';
416
446
  }
417
447
 
418
- if (request.a === ACTIONS.op || request.a === ACTIONS.presence) {
419
- if (request.v != null && (typeof request.v !== 'number' || request.v < 0)) return 'Invalid version';
420
- }
448
+ if (isInvalidVersion(request.v)) return 'Invalid version';
449
+ if (request.ts != null && typeof request.ts !== 'number') return 'Invalid timestamp';
421
450
 
422
451
  if (request.a === ACTIONS.presence) {
423
- if (typeof request.id !== 'string' || util.isDangerousProperty(request.id)) {
424
- return 'Invalid presence ID';
425
- }
452
+ if (isInvalidName(request.id)) return 'Invalid presence ID';
453
+ if (isInvalidVersion(request.pv)) return 'Invalid presence version';
426
454
  }
427
455
  } else if (
428
456
  request.a === ACTIONS.bulkFetch ||
@@ -430,18 +458,14 @@ Agent.prototype._checkRequest = function(request) {
430
458
  request.a === ACTIONS.bulkUnsubscribe
431
459
  ) {
432
460
  // Bulk request
433
- if (request.c != null) {
434
- if (typeof request.c !== 'string' || util.isDangerousProperty(request.c)) {
435
- return 'Invalid collection';
436
- }
437
- }
438
- if (typeof request.b !== 'object') return 'Invalid bulk subscribe data';
439
- }
440
- if (request.ch != null) {
441
- if (typeof request.ch !== 'string' || util.isDangerousProperty(request.ch)) {
442
- return 'Invalid presence channel';
443
- }
461
+ var bulkError = checkBulkData(request.b);
462
+ if (bulkError) return bulkError;
463
+ } else if (request.a === ACTIONS.handshake) {
464
+ if (isInvalidProtocol(request.protocol) || isInvalidProtocol(request.protocolMinor)) return 'Invalid protocol';
444
465
  }
466
+ if (request.c != null && isInvalidName(request.c)) return 'Invalid collection';
467
+ if (request.ch != null && isInvalidName(request.ch)) return 'Invalid presence channel';
468
+ if (request.seq != null && typeof request.seq !== 'number') return 'Invalid seq';
445
469
  };
446
470
 
447
471
  // Handle an incoming message from the client
@@ -540,6 +564,8 @@ function getQueryOptions(request) {
540
564
  }
541
565
  }
542
566
  var options = request.o || {};
567
+ delete options.pollInterval;
568
+ delete options.pollDebounce;
543
569
  options.ids = ids;
544
570
  options.fetch = fetch;
545
571
  options.fetchOps = fetchOps;
package/lib/backend.js CHANGED
@@ -576,15 +576,18 @@ Backend.prototype.subscribeBulk = function(agent, index, versions, callback) {
576
576
  collection: collection,
577
577
  versions: versions
578
578
  };
579
+ var subscribeFailed = false;
579
580
  async.each(ids, function(id, eachCb) {
580
581
  var channel = backend.getDocChannel(collection, id);
581
582
  backend.pubsub.subscribe(channel, function(err, stream) {
582
583
  if (err) return eachCb(err);
584
+ if (subscribeFailed) return stream.destroy();
583
585
  streams[id] = stream;
584
586
  eachCb();
585
587
  });
586
588
  }, function(err) {
587
589
  if (err) {
590
+ subscribeFailed = true;
588
591
  destroyStreams(streams);
589
592
  return callback(err);
590
593
  }
package/lib/client/doc.js CHANGED
@@ -762,7 +762,20 @@ Doc.prototype._submit = function(op, source, callback) {
762
762
  return util.callbackOrEmit(this, err, callback);
763
763
  }
764
764
  // Try to normalize the op. This removes trailing skip:0's and things like that.
765
- if (this.type.normalize) op.op = this.type.normalize(op.op);
765
+ if (this.type.normalize) {
766
+ try {
767
+ op.op = this.type.normalize(op.op);
768
+ } catch (error) {
769
+ // Otherwise a type that throws on a badly formed op throws out of
770
+ // submitOp() rather than calling back
771
+ var normalizeError = error instanceof ShareDBError ? error : new ShareDBError(
772
+ ERROR_CODE.ERR_OT_OP_BADLY_FORMED,
773
+ (error && error.message) || String(error)
774
+ );
775
+ if (callback) return callback(normalizeError);
776
+ return this.emit('error', normalizeError);
777
+ }
778
+ }
766
779
 
767
780
  // This has to happen before _pushOp(), because _tryCompose() applies the op
768
781
  // to a pending create, well before _otApply() gets a chance to check it
@@ -120,7 +120,13 @@ RemoteDocPresence.prototype._catchUpStalePresence = function() {
120
120
  this._pending.p = null;
121
121
  this._pending.v++;
122
122
  } else {
123
- ot.transformPresence(this._pending, op, isOwnOp);
123
+ var error = ot.transformPresence(this._pending, op, isOwnOp);
124
+ if (error) {
125
+ this._pending = null;
126
+ this._stopCachingOps();
127
+ this.presence.emit('error', error);
128
+ return false;
129
+ }
124
130
  }
125
131
  }
126
132
 
package/lib/ot.js CHANGED
@@ -120,6 +120,10 @@ function applyOpEdit(snapshot, edit) {
120
120
  }
121
121
  }
122
122
 
123
+ function isJson0OpComponent(component) {
124
+ return !!component && typeof component === 'object' && Array.isArray(component.p);
125
+ }
126
+
123
127
  // ot-json0 walks ops with .length and numeric indexing, so it treats array-like
124
128
  // objects as ops too, and it coerces .length. This traversal has to match it
125
129
  // exactly: anything ot-json0 will apply has to be checked here. Coercing
@@ -133,7 +137,7 @@ function checkJson0OpPaths(op) {
133
137
  var component = op[i];
134
138
  // ot-json0's checkValidOp() rejects the whole op before applying any of it,
135
139
  // so there is nothing beyond this component left to check
136
- if (!component || typeof component !== 'object' || !Array.isArray(component.p)) return;
140
+ if (!isJson0OpComponent(component)) return;
137
141
  for (var j = 0; j < component.p.length; j++) {
138
142
  if (util.isDangerousProperty(component.p[j])) {
139
143
  return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
@@ -145,13 +149,42 @@ function checkJson0OpPaths(op) {
145
149
  }
146
150
  }
147
151
 
152
+ // Only for ops arriving at submit-request, which reach ot-json0 exactly as
153
+ // they came off the wire, with nothing to normalize them on the way. Ops that
154
+ // are already committed can only have their paths checked, since ops that
155
+ // ot-json0 quietly treats as no-ops were committable by older versions of
156
+ // ShareDB
157
+ function checkSubmittedJson0Op(op) {
158
+ if (!Array.isArray(op)) {
159
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'json0 op must be an array');
160
+ }
161
+
162
+ for (var i = 0; i < op.length; i++) {
163
+ if (!isJson0OpComponent(op[i])) {
164
+ return new ShareDBError(ERROR_CODE.ERR_OT_OP_BADLY_FORMED, 'Missing path');
165
+ }
166
+ }
167
+
168
+ return checkJson0OpPaths(op);
169
+ }
170
+
171
+ function isJson0(type) {
172
+ if (typeof type === 'string') type = types.map[type];
173
+ return !!type && type.name === 'json0';
174
+ }
175
+
148
176
  exports.checkOpPathsForType = function(type, op) {
149
177
  if (!('op' in op)) return;
150
- if (typeof type === 'string') type = types.map[type];
151
- if (!type || type.name !== 'json0') return;
178
+ if (!isJson0(type)) return;
152
179
  return checkJson0OpPaths(op.op);
153
180
  };
154
181
 
182
+ exports.checkSubmittedOpForType = function(type, op) {
183
+ if (!('op' in op)) return;
184
+ if (!isJson0(type)) return;
185
+ return checkSubmittedJson0Op(op.op);
186
+ };
187
+
155
188
  exports.transform = function(type, op, appliedOp) {
156
189
  // There are 16 cases this function needs to deal with - which are all the
157
190
  // combinations of create/delete/op/noop from both op and appliedOp
@@ -229,9 +262,9 @@ exports.transformPresence = function(presence, op, isOwnOp) {
229
262
  if (typeof type === 'string') {
230
263
  type = types.map[type];
231
264
  }
232
- if (!type) return {code: ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, message: 'Unknown type'};
265
+ if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
233
266
  if (!util.supportsPresence(type)) {
234
- return {code: ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE, message: 'Type does not support presence'};
267
+ return new ShareDBError(ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE, 'Type does not support presence');
235
268
  }
236
269
 
237
270
  if (op.create || op.del) {
@@ -245,7 +278,7 @@ exports.transformPresence = function(presence, op, isOwnOp) {
245
278
  null :
246
279
  type.transformPresence(presence.p, op.op, isOwnOp);
247
280
  } catch (error) {
248
- return {code: ERROR_CODE.ERR_PRESENCE_TRANSFORM_FAILED, message: error.message || error};
281
+ return new ShareDBError(ERROR_CODE.ERR_PRESENCE_TRANSFORM_FAILED, error.message || error);
249
282
  }
250
283
 
251
284
  presence.v++;
@@ -19,6 +19,8 @@ function PubSub(options) {
19
19
  // isn't complete until the callback returns from Redis
20
20
  // Maps channel -> true
21
21
  this.subscribed = Object.create(null);
22
+ // Maps channel -> callbacks waiting on _subscribe
23
+ this._subscribing = Object.create(null);
22
24
 
23
25
  var pubsub = this;
24
26
  this._defaultCallback = function(err) {
@@ -76,11 +78,21 @@ PubSub.prototype.subscribe = function(channel, callback) {
76
78
  });
77
79
  return;
78
80
  }
81
+ if (this._subscribing[channel]) {
82
+ this._subscribing[channel].push(callback);
83
+ return;
84
+ }
85
+ var callbacks = this._subscribing[channel] = [callback];
79
86
  this._subscribe(channel, function(err) {
80
- if (err) return callback(err);
87
+ delete pubsub._subscribing[channel];
88
+ if (err) return util.callEach(callbacks, err);
81
89
  pubsub.subscribed[channel] = true;
82
- var stream = pubsub._createStream(channel);
83
- callback(null, stream);
90
+ var streams = callbacks.map(function() {
91
+ return pubsub._createStream(channel);
92
+ });
93
+ callbacks.forEach(function(callback, i) {
94
+ callback(null, streams[i]);
95
+ });
84
96
  });
85
97
  };
86
98
 
@@ -109,6 +109,12 @@ SubmitRequest.prototype.submit = function(callback) {
109
109
  request.snapshot = snapshot;
110
110
  request._addSnapshotMeta();
111
111
 
112
+ // The type is only known once we have the snapshot, so this is the earliest
113
+ // we can validate the op against it. It has to happen before the op reaches
114
+ // any type function, including through $fixup() in the apply middleware
115
+ var opError = ot.checkSubmittedOpForType(snapshot.type, op);
116
+ if (opError) return callback(opError);
117
+
112
118
  if (op.v == null) {
113
119
  if (op.create && snapshot.type && op.src) {
114
120
  // If the document was already created by another op, we will return a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "6.0.5",
3
+ "version": "7.0.0",
4
4
  "description": "JSON OT database backend",
5
5
  "main": "lib/index.js",
6
6
  "dependencies": {
package/test/agent.js CHANGED
@@ -1,4 +1,5 @@
1
1
  var Backend = require('../lib/backend');
2
+ var MemoryDb = require('../lib/db/memory');
2
3
  var logger = require('../lib/logger');
3
4
  var sinon = require('sinon');
4
5
  var StreamSocket = require('../lib/stream-socket');
@@ -19,6 +20,141 @@ describe('Agent', function() {
19
20
  backend.close(done);
20
21
  });
21
22
 
23
+ describe('request validation', function() {
24
+ var socket;
25
+ var connection;
26
+
27
+ beforeEach(function(done) {
28
+ socket = new StreamSocket();
29
+ backend.listen(socket.stream);
30
+ connection = new Connection(socket);
31
+ socket._open();
32
+ connection.once('connected', function() {
33
+ done();
34
+ });
35
+ });
36
+
37
+ function expectRejected(message, expectedError, done) {
38
+ var dbReads = ['getSnapshot', 'getSnapshotBulk', 'getOps', 'getOpsBulk', 'query'].map(function(method) {
39
+ return sinon.spy(MemoryDb.prototype, method);
40
+ });
41
+ connection.on('receive', function(request) {
42
+ var reply = request.data;
43
+ if (!reply || !reply.error) return;
44
+ request.data = null;
45
+ expect(reply.error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: expectedError});
46
+ dbReads.forEach(function(dbRead) {
47
+ expect(dbRead).not.to.have.been.called;
48
+ });
49
+ done();
50
+ });
51
+ socket.send(JSON.stringify(message));
52
+ }
53
+
54
+ var unconvertible = {toString: 1};
55
+ var badIds = [unconvertible, 42, null, '__proto__'];
56
+ var badVersions = [unconvertible, '1', -1];
57
+
58
+ [ACTIONS.fetch, ACTIONS.subscribe, ACTIONS.snapshotFetch].forEach(function(action) {
59
+ badVersions.forEach(function(badVersion) {
60
+ it('rejects a ' + action + ' with version ' + JSON.stringify(badVersion), function(done) {
61
+ expectRejected({a: action, c: 'dogs', d: 'fido', v: badVersion}, 'Invalid version', done);
62
+ });
63
+ });
64
+ });
65
+
66
+ [unconvertible, '1'].forEach(function(badTimestamp) {
67
+ it('rejects a snapshot fetch with timestamp ' + JSON.stringify(badTimestamp), function(done) {
68
+ var message = {a: ACTIONS.snapshotFetchByTimestamp, c: 'dogs', d: 'fido', ts: badTimestamp};
69
+ expectRejected(message, 'Invalid timestamp', done);
70
+ });
71
+ });
72
+
73
+ [ACTIONS.bulkFetch, ACTIONS.bulkSubscribe, ACTIONS.bulkUnsubscribe].forEach(function(action) {
74
+ it('rejects a ' + action + ' without bulk data', function(done) {
75
+ expectRejected({a: action, c: 'dogs', b: null}, 'Invalid bulk subscribe data', done);
76
+ });
77
+
78
+ badIds.forEach(function(badId) {
79
+ it('rejects a ' + action + ' with id ' + JSON.stringify(badId), function(done) {
80
+ expectRejected({a: action, c: 'dogs', b: ['fido', badId]}, 'Invalid id', done);
81
+ });
82
+ });
83
+
84
+ badVersions.forEach(function(badVersion) {
85
+ it('rejects a ' + action + ' with version ' + JSON.stringify(badVersion), function(done) {
86
+ expectRejected({a: action, c: 'dogs', b: {fido: badVersion}}, 'Invalid version', done);
87
+ });
88
+ });
89
+
90
+ it('rejects a ' + action + ' with a __proto__ key in its version map', function(done) {
91
+ expectRejected({a: action, c: 'dogs', b: JSON.parse('{"__proto__": 0}')}, 'Invalid id', done);
92
+ });
93
+ });
94
+
95
+ [ACTIONS.queryFetch, ACTIONS.querySubscribe].forEach(function(action) {
96
+ [unconvertible, ['dogs'], '__proto__'].forEach(function(badCollection) {
97
+ it('rejects a ' + action + ' with collection ' + JSON.stringify(badCollection), function(done) {
98
+ expectRejected({a: action, id: 1, c: badCollection, q: {}}, 'Invalid collection', done);
99
+ });
100
+ });
101
+
102
+ badIds.forEach(function(badId) {
103
+ it('rejects a ' + action + ' with result id ' + JSON.stringify(badId), function(done) {
104
+ expectRejected({a: action, id: 1, c: 'dogs', q: {}, r: [[badId]]}, 'Invalid id', done);
105
+ });
106
+ });
107
+
108
+ badVersions.forEach(function(badVersion) {
109
+ it('rejects a ' + action + ' with result version ' + JSON.stringify(badVersion), function(done) {
110
+ expectRejected({a: action, id: 1, c: 'dogs', q: {}, r: [['fido', badVersion]]}, 'Invalid version', done);
111
+ });
112
+ });
113
+
114
+ [{fido: 0}, ['fido']].forEach(function(badResults) {
115
+ it('rejects a ' + action + ' with results ' + JSON.stringify(badResults), function(done) {
116
+ expectRejected({a: action, id: 1, c: 'dogs', q: {}, r: badResults}, 'Invalid query results', done);
117
+ });
118
+ });
119
+ });
120
+
121
+ it('accepts a query unsubscribe without a collection', function(done) {
122
+ connection.on('receive', function(request) {
123
+ var reply = request.data;
124
+ if (!reply || reply.a !== ACTIONS.queryUnsubscribe) return;
125
+ request.data = null;
126
+ expect(reply).not.to.have.property('error');
127
+ done();
128
+ });
129
+ socket.send(JSON.stringify({a: ACTIONS.queryUnsubscribe, id: 1}));
130
+ });
131
+
132
+ [ACTIONS.presenceSubscribe, ACTIONS.presenceUnsubscribe].forEach(function(action) {
133
+ [unconvertible, '1'].forEach(function(badSeq) {
134
+ it('rejects a ' + action + ' with seq ' + JSON.stringify(badSeq), function(done) {
135
+ expectRejected({a: action, ch: 'dogs.fido', seq: badSeq}, 'Invalid seq', done);
136
+ });
137
+ });
138
+ });
139
+
140
+ badVersions.forEach(function(badVersion) {
141
+ it('rejects presence with presence version ' + JSON.stringify(badVersion), function(done) {
142
+ var message = {a: ACTIONS.presence, ch: 'dogs.fido', id: 'rex', p: null, pv: badVersion};
143
+ expectRejected(message, 'Invalid presence version', done);
144
+ });
145
+ });
146
+
147
+ [unconvertible, true].forEach(function(badProtocol) {
148
+ it('rejects a handshake with protocol ' + JSON.stringify(badProtocol), function(done) {
149
+ expectRejected({a: ACTIONS.handshake, protocol: badProtocol, protocolMinor: 2}, 'Invalid protocol', done);
150
+ });
151
+
152
+ it('rejects a handshake with minor protocol ' + JSON.stringify(badProtocol), function(done) {
153
+ expectRejected({a: ACTIONS.handshake, protocol: 1, protocolMinor: badProtocol}, 'Invalid protocol', done);
154
+ });
155
+ });
156
+ });
157
+
22
158
  describe('handshake', function() {
23
159
  it('warns when messages are sent before the handshake', function(done) {
24
160
  var socket = new StreamSocket();
package/test/backend.js CHANGED
@@ -248,7 +248,7 @@ describe('Backend', function() {
248
248
  title: '1984',
249
249
  author: 'George Orwell'
250
250
  });
251
- var op = {op: {p: ['publication'], oi: 1949}};
251
+ var op = {op: [{p: ['publication'], oi: 1949}]};
252
252
  stream.on('data', function(data) {
253
253
  expect(data.op).to.eql(op.op);
254
254
  done();
@@ -279,6 +279,37 @@ describe('Backend', function() {
279
279
  done();
280
280
  });
281
281
  });
282
+
283
+ describe('when one subscribe fails', function() {
284
+ var finishSubscribe;
285
+
286
+ beforeEach(function() {
287
+ finishSubscribe = {};
288
+ backend.pubsub._subscribe = function(channel, callback) {
289
+ finishSubscribe[channel] = callback;
290
+ };
291
+ });
292
+
293
+ it('destroys streams that subscribed before the failure', function(done) {
294
+ backend.subscribeBulk(agent, 'books', ['1984', 'bad'], function(error) {
295
+ expect(error.message).to.equal('subscribe failed');
296
+ expect(backend.pubsub.streamsCount).to.equal(0);
297
+ done();
298
+ });
299
+ finishSubscribe['books.1984']();
300
+ finishSubscribe['books.bad'](new Error('subscribe failed'));
301
+ });
302
+
303
+ it('destroys streams that subscribe after the failure', function(done) {
304
+ backend.subscribeBulk(agent, 'books', ['1984', 'bad'], function(error) {
305
+ expect(error.message).to.equal('subscribe failed');
306
+ finishSubscribe['books.1984']();
307
+ expect(backend.pubsub.streamsCount).to.equal(0);
308
+ done();
309
+ });
310
+ finishSubscribe['books.bad'](new Error('subscribe failed'));
311
+ });
312
+ });
282
313
  });
283
314
 
284
315
  describe('submitRequestEnd', function() {
@@ -297,7 +328,7 @@ describe('Backend', function() {
297
328
  done();
298
329
  });
299
330
 
300
- var op = {op: {p: ['publicationYear'], oi: 1949}};
331
+ var op = {op: [{p: ['publicationYear'], oi: 1949}]};
301
332
  backend.submit(agent, 'books', '1984', op, null, function(error) {
302
333
  if (error) done(error);
303
334
  });
@@ -314,7 +345,7 @@ describe('Backend', function() {
314
345
  done();
315
346
  });
316
347
 
317
- var op = {op: {p: ['publicationYear'], oi: 1949}};
348
+ var op = {op: [{p: ['publicationYear'], oi: 1949}]};
318
349
  backend.submit(agent, 'books', '1984', op, null, function() {
319
350
  // Swallow the error
320
351
  });
@@ -657,31 +657,31 @@ describe('Doc', function() {
657
657
  });
658
658
  });
659
659
 
660
- describe('errors on ops that could cause prototype corruption', function() {
661
- function expectReceiveError(
662
- connection,
663
- collectionName,
664
- docId,
665
- expectedError,
666
- done
667
- ) {
668
- connection.on('receive', function(request) {
669
- var message = request.data;
670
- if (message.c === collectionName && message.d === docId) {
671
- if ('error' in message) {
672
- request.data = null; // Stop further processing of the message
673
- if (message.error.message === expectedError) {
674
- return done();
675
- } else {
676
- return done('Unexpected ShareDB error: ' + message.error.message);
677
- }
660
+ function expectReceiveError(
661
+ connection,
662
+ collectionName,
663
+ docId,
664
+ expectedError,
665
+ done
666
+ ) {
667
+ connection.on('receive', function(request) {
668
+ var message = request.data;
669
+ if (message.c === collectionName && message.d === docId) {
670
+ if ('error' in message) {
671
+ request.data = null; // Stop further processing of the message
672
+ if (message.error.message === expectedError) {
673
+ return done();
678
674
  } else {
679
- return done('Expected error on ' + collectionName + '.' + docId + ' but got no error');
675
+ return done('Unexpected ShareDB error: ' + message.error.message);
680
676
  }
677
+ } else {
678
+ return done('Expected error on ' + collectionName + '.' + docId + ' but got no error');
681
679
  }
682
- });
683
- }
680
+ }
681
+ });
682
+ }
684
683
 
684
+ describe('errors on ops that could cause prototype corruption', function() {
685
685
  afterEach(function() {
686
686
  delete Object.prototype.polluted;
687
687
  });
@@ -760,16 +760,21 @@ describe('Doc', function() {
760
760
  });
761
761
  });
762
762
 
763
- // ot-json0 walks ops with .length and numeric indexing, so it applies an
764
- // array-like object as if it were an op
765
763
  [
766
764
  {
767
765
  name: 'an array-like op',
768
- op: {0: {p: ['__proto__', 'polluted'], oi: 'oops'}, length: 1}
766
+ op: {0: {p: ['__proto__', 'polluted'], oi: 'oops'}, length: 1},
767
+ error: 'json0 op must be an array'
769
768
  },
770
769
  {
771
770
  name: 'ops with a path segment that is not a string',
772
- op: [{p: [['__proto__'], 'polluted'], oi: 'oops'}]
771
+ op: [{p: [['__proto__'], 'polluted'], oi: 'oops'}],
772
+ error: 'Invalid path segment'
773
+ },
774
+ {
775
+ name: 'ops with a component that is not an object',
776
+ op: [null],
777
+ error: 'Missing path'
773
778
  }
774
779
  ].forEach(function(test) {
775
780
  it('Rejects ' + test.name, function(done) {
@@ -780,7 +785,7 @@ describe('Doc', function() {
780
785
  if (err) {
781
786
  return done(err);
782
787
  }
783
- expectReceiveError(connection, collectionName, docId, 'Invalid path segment', function(error) {
788
+ expectReceiveError(connection, collectionName, docId, test.error, function(error) {
784
789
  if (error) {
785
790
  return done(error);
786
791
  }
@@ -911,6 +916,31 @@ describe('Doc', function() {
911
916
  });
912
917
  });
913
918
 
919
+ // normalize() runs before the paths are checked, and it takes a bare
920
+ // component as well as an op, fills in a missing path, and branches on
921
+ // Array.isArray() — so these all reach the check as a real op
922
+ [
923
+ {name: 'a bare op component', op: {p: ['__proto__', 'polluted'], oi: 'oops'}},
924
+ {name: 'an op that is also shaped like a component', op: (function() {
925
+ var op = [{p: ['__proto__', 'polluted'], oi: 'oops'}];
926
+ op.p = [];
927
+ return op;
928
+ })()},
929
+ {name: 'an op with a pathless component before a bad one', op: [
930
+ {p: [], od: {foo: 'bar'}, oi: {foo: 'bar'}},
931
+ {p: ['__proto__', 'polluted'], oi: 'oops'}
932
+ ]}
933
+ ].forEach(function(test) {
934
+ it('rejects ' + test.name + ' composed into a pending create', function(done) {
935
+ var doc = this.connection.get('test-collection', 'test-doc');
936
+ doc.create({foo: 'bar'});
937
+ doc.submitOp(test.op, function(error) {
938
+ expectInvalidPathSegment(error);
939
+ done();
940
+ });
941
+ });
942
+ });
943
+
914
944
  it('leaves the doc usable after rejecting an op', function(done) {
915
945
  var doc = this.connection.get('test-collection', 'test-doc');
916
946
  async.series([
@@ -932,6 +962,165 @@ describe('Doc', function() {
932
962
  });
933
963
  });
934
964
 
965
+ // ot-json0's apply() quietly ignores an op that isn't an array, but its
966
+ // compose() and invert() throw on one. $fixup() composes, and it is called
967
+ // from middleware, so the throw is uncaught and takes the process down
968
+ describe('errors on badly formed json0 ops', function() {
969
+ [
970
+ {
971
+ name: 'an array-like op',
972
+ op: {0: {p: ['colour'], oi: 'red'}, length: 1},
973
+ error: 'json0 op must be an array'
974
+ },
975
+ {
976
+ name: 'a bare op component',
977
+ op: {p: ['colour'], oi: 'red'},
978
+ error: 'json0 op must be an array'
979
+ },
980
+ {
981
+ name: 'an op component that is not an object',
982
+ op: [null],
983
+ error: 'Missing path'
984
+ }
985
+ ].forEach(function(test) {
986
+ it('Rejects ' + test.name + ' before the apply middleware can fix it up', function(done) {
987
+ var connection = this.connection;
988
+ var collectionName = 'test-collection';
989
+ var docId = 'test-doc';
990
+ this.backend.use('apply', function(request, next) {
991
+ if ('op' in request.op) request.$fixup([{p: ['fixed'], oi: true}]);
992
+ next();
993
+ });
994
+ connection.get(collectionName, docId).create({id: docId}, function(err) {
995
+ if (err) {
996
+ return done(err);
997
+ }
998
+ expectReceiveError(connection, collectionName, docId, test.error, done);
999
+ connection.send({
1000
+ a: 'op',
1001
+ c: collectionName,
1002
+ d: docId,
1003
+ v: 1,
1004
+ seq: connection.seq++,
1005
+ x: {},
1006
+ op: test.op
1007
+ });
1008
+ });
1009
+ });
1010
+ });
1011
+
1012
+ describe('locally submitted ops', function() {
1013
+ // json0's normalize() throws on a component that isn't an object, and it
1014
+ // runs before the op is checked, so this used to come straight out of
1015
+ // submitOp() rather than through the callback
1016
+ it('rejects an op component that is not an object without sending it to the server', function(done) {
1017
+ var doc = this.connection.get('test-collection', 'test-doc');
1018
+ doc.create({foo: 'bar'}, function(error) {
1019
+ if (error) return done(error);
1020
+ var calledBack = false;
1021
+ doc.submitOp([null], function(error) {
1022
+ calledBack = true;
1023
+ expect(error.code).to.equal(ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED);
1024
+ });
1025
+ // The server would only reject asynchronously, so calling back
1026
+ // synchronously is how we know the op never left the client
1027
+ expect(calledBack).to.equal(true);
1028
+ done();
1029
+ });
1030
+ });
1031
+
1032
+ it('emits an error for an op component that is not an object with no callback', function(done) {
1033
+ var doc = this.connection.get('test-collection', 'test-doc');
1034
+ doc.create({foo: 'bar'}, function(error) {
1035
+ if (error) return done(error);
1036
+ doc.on('error', function(error) {
1037
+ expect(error.code).to.equal(ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED);
1038
+ done();
1039
+ });
1040
+ doc.submitOp([null]);
1041
+ });
1042
+ });
1043
+
1044
+ // A type can throw anything at all, and reading .message off a thrown
1045
+ // null would throw out of the catch, which is what the catch is for
1046
+ [
1047
+ {
1048
+ name: 'a string',
1049
+ thrown: 'not an op',
1050
+ code: ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED,
1051
+ message: 'not an op'
1052
+ },
1053
+ {
1054
+ name: 'null',
1055
+ thrown: null,
1056
+ code: ShareDBError.CODES.ERR_OT_OP_BADLY_FORMED,
1057
+ message: 'null'
1058
+ },
1059
+ {
1060
+ name: 'a ShareDBError',
1061
+ thrown: new ShareDBError('ERR_CUSTOM_TYPE_ERROR', 'Custom type error'),
1062
+ code: 'ERR_CUSTOM_TYPE_ERROR',
1063
+ message: 'Custom type error'
1064
+ }
1065
+ ].forEach(function(test) {
1066
+ it('reports ' + test.name + ' thrown by normalize()', function(done) {
1067
+ var doc = this.connection.get('test-collection', 'test-doc');
1068
+ doc.create({foo: 'bar'}, function(error) {
1069
+ if (error) return done(error);
1070
+ sinon.stub(json0, 'normalize').callsFake(function() {
1071
+ throw test.thrown;
1072
+ });
1073
+ doc.submitOp([{p: ['foo'], od: 'bar'}], function(error) {
1074
+ expect(error.code).to.equal(test.code);
1075
+ expect(error.message).to.equal(test.message);
1076
+ done();
1077
+ });
1078
+ });
1079
+ });
1080
+ });
1081
+
1082
+ // normalize() accepts more than the server does, and everything it
1083
+ // accepts has to stay submittable
1084
+ [
1085
+ {name: 'a bare op component', op: {p: ['baz'], oi: true}},
1086
+ {name: 'an op whose component has no path', op: [{od: {foo: 'bar'}, oi: {foo: 'bar', baz: true}}]},
1087
+ {name: 'a bare component with no path', op: {od: {foo: 'bar'}, oi: {foo: 'bar', baz: true}}}
1088
+ ].forEach(function(test) {
1089
+ it('accepts ' + test.name + ', which json0 normalizes into an op', function(done) {
1090
+ var doc = this.connection.get('test-collection', 'test-doc');
1091
+ async.series([
1092
+ doc.create.bind(doc, {foo: 'bar'}),
1093
+ doc.submitOp.bind(doc, test.op),
1094
+ doc.whenNothingPending.bind(doc),
1095
+ function(next) {
1096
+ expect(doc.data).to.eql({foo: 'bar', baz: true});
1097
+ next();
1098
+ }
1099
+ ], done);
1100
+ });
1101
+ });
1102
+
1103
+ it('leaves the doc usable after rejecting an op', function(done) {
1104
+ var doc = this.connection.get('test-collection', 'test-doc');
1105
+ async.series([
1106
+ doc.create.bind(doc, {foo: 'bar'}),
1107
+ function(next) {
1108
+ doc.submitOp([null], function(error) {
1109
+ expect(error).to.be.instanceOf(Error);
1110
+ next();
1111
+ });
1112
+ },
1113
+ doc.submitOp.bind(doc, [{p: ['baz'], oi: true}]),
1114
+ doc.whenNothingPending.bind(doc),
1115
+ function(next) {
1116
+ expect(doc.data).to.eql({foo: 'bar', baz: true});
1117
+ next();
1118
+ }
1119
+ ], done);
1120
+ });
1121
+ });
1122
+ });
1123
+
935
1124
  describe('toSnapshot', function() {
936
1125
  var doc;
937
1126
  beforeEach(function(done) {
@@ -6,6 +6,7 @@ var presenceTestType = require('./presence-test-type');
6
6
  var errorHandler = require('../../util').errorHandler;
7
7
  var PresencePauser = require('./presence-pauser');
8
8
  var sinon = require('sinon');
9
+ var ACTIONS = require('../../../lib/message-actions').ACTIONS;
9
10
  types.register(presenceTestType.type);
10
11
 
11
12
  describe('DocPresence', function() {
@@ -971,6 +972,59 @@ describe('DocPresence', function() {
971
972
  ], done);
972
973
  });
973
974
 
975
+ it('emits an error and drops stale remote presence that cannot be transformed', function(done) {
976
+ var localPresence1 = presence1.create('presence-1');
977
+ var catchUpCount = 0;
978
+
979
+ function sendUntypedPresenceAtVersion(version) {
980
+ var message = localPresence1._message();
981
+ message.d = null;
982
+ message.p = {index: 0};
983
+ message.v = version;
984
+ connection1.send(message);
985
+ }
986
+
987
+ async.series([
988
+ presence2.subscribe.bind(presence2),
989
+ function(next) {
990
+ connection2.on('send', function(message) {
991
+ if (message.a !== ACTIONS.presenceRequest) return;
992
+ catchUpCount++;
993
+ if (catchUpCount === 1) next();
994
+ });
995
+ sendUntypedPresenceAtVersion(0);
996
+ },
997
+ function(next) {
998
+ doc2.once('op', function() {
999
+ next();
1000
+ });
1001
+ doc1.submitOp({index: 5, value: 'ern'}, errorHandler(done));
1002
+ },
1003
+ function(next) {
1004
+ presence2.once('error', function(error) {
1005
+ expect(error).to.be.an.instanceOf(Error);
1006
+ expect(error.code).to.equal('ERR_DOC_TYPE_NOT_RECOGNIZED');
1007
+ next();
1008
+ });
1009
+ sendUntypedPresenceAtVersion(1);
1010
+ },
1011
+ function(next) {
1012
+ doc2.once('op', function() {
1013
+ next();
1014
+ });
1015
+ doc1.submitOp({index: 0, value: 'The'}, errorHandler(done));
1016
+ },
1017
+ function(next) {
1018
+ presence2.once('receive', function(id, presence) {
1019
+ expect(presence).to.eql({index: 1});
1020
+ expect(catchUpCount).to.equal(1);
1021
+ next();
1022
+ });
1023
+ localPresence1.submit({index: 1}, errorHandler(done));
1024
+ }
1025
+ ], done);
1026
+ });
1027
+
974
1028
  it('sends null presence when the doc is destroyed', function(done) {
975
1029
  var localPresence1 = presence1.create('presence-1');
976
1030
 
@@ -127,11 +127,13 @@ module.exports = function(options) {
127
127
 
128
128
  it('does not duplicate messages', function(done) {
129
129
  var connection = this.backend.connect();
130
+ this.backend.db.pollInterval = 0;
131
+ this.backend.db.pollDebounce = 0;
130
132
  var count = 0;
131
133
  var query = connection.createSubscribeQuery(
132
134
  'dogs',
133
135
  this.matchAllDbQuery,
134
- {pollInterval: 0, pollDebounce: 0},
136
+ null,
135
137
  function(err) {
136
138
  if (err) return done(err);
137
139
  connection.get('dogs', '1').on('error', done).create({});
@@ -519,13 +521,17 @@ function commonTests(options) {
519
521
  });
520
522
  });
521
523
 
522
- it('pollDebounce option reduces subsequent poll interval', function(done) {
524
+ it('query middleware pollDebounce option reduces subsequent poll interval', function(done) {
523
525
  var clock = util.useFakeTimers();
524
526
  var connection = this.backend.connect();
525
527
  this.backend.db.canPollDoc = function() {
526
528
  return false;
527
529
  };
528
- var query = connection.createSubscribeQuery('items', this.matchAllDbQuery, {pollDebounce: 2000});
530
+ this.backend.use('query', function(context, next) {
531
+ context.options.pollDebounce = 2000;
532
+ next();
533
+ });
534
+ var query = connection.createSubscribeQuery('items', this.matchAllDbQuery);
529
535
  query.on('error', done);
530
536
  var batchSizes = [];
531
537
  var total = 0;
@@ -618,21 +624,21 @@ function commonTests(options) {
618
624
  });
619
625
  });
620
626
 
621
- it('pollInterval updates a subscribed query after an unpublished create', function(done) {
627
+ it('query middleware pollInterval updates a subscribed query after an unpublished create', function(done) {
622
628
  var clock = util.useFakeTimers();
623
629
  var connection = this.backend.connect();
624
630
  this.backend.suppressPublish = true;
625
- var query = connection.createSubscribeQuery(
626
- 'dogs',
627
- this.matchAllDbQuery,
628
- {pollDebounce: 0, pollInterval: 50},
629
- function(err) {
630
- if (err) return done(err);
631
- connection.get('dogs', 'fido').on('error', done).create({}, function() {
632
- clock.tick(51);
633
- });
634
- }
635
- );
631
+ this.backend.use('query', function(context, next) {
632
+ context.options.pollDebounce = 0;
633
+ context.options.pollInterval = 50;
634
+ next();
635
+ });
636
+ var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) {
637
+ if (err) return done(err);
638
+ connection.get('dogs', 'fido').on('error', done).create({}, function() {
639
+ clock.tick(51);
640
+ });
641
+ });
636
642
  query.on('error', done);
637
643
  query.on('insert', function(docs) {
638
644
  expect(util.pluck(docs, 'id')).eql(['fido']);
@@ -659,13 +665,17 @@ function commonTests(options) {
659
665
  });
660
666
  });
661
667
 
662
- it('pollInterval captures additional unpublished creates', function(done) {
668
+ it('query middleware pollInterval captures additional unpublished creates', function(done) {
663
669
  var clock = util.useFakeTimers();
664
670
  var connection = this.backend.connect();
665
671
  this.backend.suppressPublish = true;
672
+ this.backend.use('query', function(context, next) {
673
+ context.options.pollInterval = 1000;
674
+ next();
675
+ });
666
676
  var count = 0;
667
677
 
668
- var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 1000}, function(err) {
678
+ var query = connection.createSubscribeQuery('dogs', this.matchAllDbQuery, null, function(err) {
669
679
  if (err) return done(err);
670
680
  var doc = connection.get('dogs', count.toString()).on('error', done);
671
681
  doc.create({}, function(e) {
@@ -686,6 +696,34 @@ function commonTests(options) {
686
696
  clock.tick(1);
687
697
  });
688
698
 
699
+ it('does not pass client pollInterval or pollDebounce to query middleware', function(done) {
700
+ var connection = this.backend.connect();
701
+ var queryOptions;
702
+ this.backend.use('query', function(context, next) {
703
+ queryOptions = context.options;
704
+ next();
705
+ });
706
+ connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 1, pollDebounce: 0}, function(err) {
707
+ if (err) return done(err);
708
+ expect(queryOptions).not.to.have.any.keys('pollInterval', 'pollDebounce');
709
+ done();
710
+ });
711
+ });
712
+
713
+ it('ignores pollInterval from client query options', function(done) {
714
+ var clock = util.useFakeTimers();
715
+ var connection = this.backend.connect();
716
+ var db = this.backend.db;
717
+ db.pollInterval = 0;
718
+ sinon.spy(db, 'queryPoll');
719
+ connection.createSubscribeQuery('dogs', this.matchAllDbQuery, {pollInterval: 50}, function(err) {
720
+ if (err) return done(err);
721
+ clock.tick(1000);
722
+ expect(db.queryPoll.called).to.be.false;
723
+ done();
724
+ });
725
+ });
726
+
689
727
  it('query extra is returned to client', function(done) {
690
728
  var connection = this.backend.connect();
691
729
  this.backend.db.query = function(collection, query, fields, options, callback) {
@@ -906,7 +944,7 @@ function commonTests(options) {
906
944
  connection.createSubscribeQuery(
907
945
  'dogs',
908
946
  this.matchAllDbQuery,
909
- {pollInterval: 0, pollDebounce: 0},
947
+ null,
910
948
  function(err) {
911
949
  if (err) {
912
950
  expect(err.message).to.be.equal('TEST_ERROR');
@@ -5,6 +5,7 @@ var types = require('../lib/types');
5
5
  var errorHandler = util.errorHandler;
6
6
  var ShareDBError = require('../lib/error');
7
7
  var sinon = require('sinon');
8
+ var crypto = require('crypto');
8
9
  var ACTIONS = require('../lib/message-actions').ACTIONS;
9
10
 
10
11
  var ERROR_CODE = ShareDBError.CODES;
@@ -63,6 +64,101 @@ describe('middleware', function() {
63
64
  done();
64
65
  });
65
66
  });
67
+
68
+ it('uses a clientId set on connect as the connection id and op src', function(done) {
69
+ var backend = this.backend;
70
+ backend.use('connect', function(request, next) {
71
+ request.agent.clientId = 'alice:1';
72
+ next();
73
+ });
74
+
75
+ var srcs = [];
76
+ backend.use('submit', function(request, next) {
77
+ srcs.push(request.op.src);
78
+ next();
79
+ });
80
+
81
+ backend.connect(null, null, function(connection) {
82
+ expect(connection.id).to.equal('alice:1');
83
+ connection.get('dogs', 'fido').create({age: 3}, function(error) {
84
+ if (error) return done(error);
85
+ expect(srcs).to.eql(['alice:1']);
86
+ done();
87
+ });
88
+ });
89
+ });
90
+ });
91
+
92
+ describe('restricting op sources', function() {
93
+ beforeEach(function() {
94
+ function srcFor(userId) {
95
+ return userId + ':' + crypto.randomUUID();
96
+ }
97
+
98
+ function isSrcOf(userId, src) {
99
+ if (typeof src !== 'string') return false;
100
+ var separator = src.lastIndexOf(':');
101
+ return separator !== -1 && src.slice(0, separator) === String(userId);
102
+ }
103
+
104
+ this.backend.use('connect', function(context, next) {
105
+ var userId = context.req.userId;
106
+ context.agent.custom.userId = userId;
107
+ context.agent.clientId = srcFor(userId);
108
+ next();
109
+ });
110
+
111
+ this.backend.use('receive', function(context, next) {
112
+ var message = context.data;
113
+ var userId = context.agent.custom.userId;
114
+ if (message.a === ACTIONS.handshake && message.id != null && !isSrcOf(userId, message.id)) {
115
+ delete message.id;
116
+ }
117
+ if (message.a === ACTIONS.op && message.src != null && !isSrcOf(userId, message.src)) {
118
+ return next(new Error('Op src belongs to another user'));
119
+ }
120
+ next();
121
+ });
122
+ });
123
+
124
+ it('keeps a reclaimed id that names the same user', function(done) {
125
+ var backend = this.backend;
126
+ backend.connect(null, {userId: 42}, function(connection) {
127
+ var id = connection.id;
128
+ expect(id).to.match(/^42:/);
129
+ connection.close();
130
+ backend.connect(connection, {userId: 42}, function() {
131
+ expect(connection.id).to.equal(id);
132
+ done();
133
+ });
134
+ });
135
+ });
136
+
137
+ it('replaces a reclaimed id that names another user', function(done) {
138
+ var backend = this.backend;
139
+ backend.connect(null, {userId: 42}, function(connection) {
140
+ connection.close();
141
+ connection.id = '7:stale';
142
+ backend.connect(connection, {userId: 42}, function() {
143
+ expect(connection.id).to.match(/^42:/);
144
+ expect(connection.id).to.equal(connection.agent.clientId);
145
+ done();
146
+ });
147
+ });
148
+ });
149
+
150
+ it('rejects an op whose src names another user', function(done) {
151
+ this.backend.connect(null, {userId: 42}, function(connection) {
152
+ connection.get('dogs', 'fido').on('error', function(error) {
153
+ expect(error.message).to.equal('Op src belongs to another user');
154
+ done();
155
+ });
156
+ connection.send({
157
+ a: ACTIONS.op, c: 'dogs', d: 'fido', v: 0, src: '7:1', seq: 1,
158
+ create: {type: 'json0', data: {}}
159
+ });
160
+ });
161
+ });
66
162
  });
67
163
 
68
164
  describe('readSnapshots', function() {
package/test/ot.js CHANGED
@@ -203,6 +203,52 @@ describe('ot', function() {
203
203
  });
204
204
  });
205
205
 
206
+ describe('checkSubmittedOpForType', function() {
207
+ it('rejects an array-like json0 op', function() {
208
+ var op = {op: {0: {p: ['colour'], oi: 'red'}, length: 1}};
209
+ var error = ot.checkSubmittedOpForType(type.uri, op);
210
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_BADLY_FORMED);
211
+ });
212
+
213
+ it('rejects a json0 op that is not an array', function() {
214
+ var error = ot.checkSubmittedOpForType(type.uri, {op: {p: ['colour'], oi: 'red'}});
215
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_BADLY_FORMED);
216
+ });
217
+
218
+ it('rejects a json0 op component that is not an object', function() {
219
+ var error = ot.checkSubmittedOpForType(type.uri, {op: [null]});
220
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_BADLY_FORMED);
221
+ expect(error.message).to.equal('Missing path');
222
+ });
223
+
224
+ it('rejects a dangerous path segment', function() {
225
+ var error = ot.checkSubmittedOpForType(type.uri, {op: [{p: ['__proto__', 'x'], oi: 'yes'}]});
226
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
227
+ expect(error.message).to.equal('Invalid path segment');
228
+ });
229
+
230
+ it('returns an error for a path segment that cannot be converted to a key', function() {
231
+ var error;
232
+ expect(function() {
233
+ error = ot.checkSubmittedOpForType(type.uri, {op: [{p: ['colour', {toString: 'x'}], oi: 'yes'}]});
234
+ }).to.not.throw();
235
+ expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
236
+ });
237
+
238
+ it('accepts a valid json0 op', function() {
239
+ expect(ot.checkSubmittedOpForType(type.uri, {op: [{p: ['colour'], oi: 'red'}]})).equal();
240
+ });
241
+
242
+ it('leaves ops for other types alone', function() {
243
+ expect(ot.checkSubmittedOpForType(presenceType.uri, {op: {index: 0, value: 'hi'}})).equal();
244
+ });
245
+
246
+ it('leaves creates and deletes alone', function() {
247
+ expect(ot.checkSubmittedOpForType(type.uri, {create: {type: type.uri}})).equal();
248
+ expect(ot.checkSubmittedOpForType(type.uri, {del: true})).equal();
249
+ });
250
+ });
251
+
206
252
  describe('no-op', function() {
207
253
  it('works on existing docs', function() {
208
254
  var doc = {v: 6, type: type.uri, data: 'Hi'};
@@ -1,6 +1,7 @@
1
1
  var MemoryPubSub = require('../lib/pubsub/memory');
2
2
  var PubSub = require('../lib/pubsub');
3
3
  var expect = require('chai').expect;
4
+ var sinon = require('sinon');
4
5
 
5
6
  require('./pubsub')(function(callback) {
6
7
  callback(null, new MemoryPubSub());
@@ -29,6 +30,50 @@ describe('PubSub base class', function() {
29
30
  pubsub.subscribe('x');
30
31
  });
31
32
 
33
+ it('returns the _subscribe error to every concurrent subscriber', function(done) {
34
+ var pubsub = new PubSub();
35
+ sinon.spy(pubsub, '_subscribe');
36
+ var errors = 0;
37
+ function onSubscribe(err) {
38
+ expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED');
39
+ if (++errors < 2) return;
40
+ expect(pubsub._subscribe).to.have.been.calledOnce;
41
+ done();
42
+ }
43
+ pubsub.subscribe('x', onSubscribe);
44
+ pubsub.subscribe('x', onSubscribe);
45
+ });
46
+
47
+ it('calls _subscribe again after a failed subscribe', function(done) {
48
+ var pubsub = new PubSub();
49
+ pubsub.subscribe('x', function(err) {
50
+ expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED');
51
+ pubsub._subscribe = function(channel, callback) {
52
+ callback();
53
+ };
54
+ pubsub.subscribe('x', function(err) {
55
+ if (err) return done(err);
56
+ expect(pubsub.streamsCount).to.equal(1);
57
+ done();
58
+ });
59
+ });
60
+ });
61
+
62
+ it('can resubscribe from a callback that destroyed the last stream', function(done) {
63
+ var pubsub = new PubSub();
64
+ pubsub._subscribe = function(channel, callback) {
65
+ callback();
66
+ };
67
+ pubsub._unsubscribe = function(channel, callback) {
68
+ callback();
69
+ };
70
+ pubsub.subscribe('x', function(err, stream) {
71
+ if (err) return done(err);
72
+ stream.destroy();
73
+ pubsub.subscribe('x', done);
74
+ });
75
+ });
76
+
32
77
  it('emits an error if _unsubscribe is unimplemented', function(done) {
33
78
  var pubsub = new PubSub();
34
79
  pubsub._subscribe = function(channel, callback) {
package/test/pubsub.js CHANGED
@@ -60,6 +60,51 @@ module.exports = function(create) {
60
60
  });
61
61
  });
62
62
 
63
+ it('calls _subscribe once for concurrent subscribes to a channel', function(done) {
64
+ var pubsub = this.pubsub;
65
+ var _subscribe = pubsub._subscribe;
66
+ var subscribeCalls = 0;
67
+ pubsub._subscribe = function() {
68
+ subscribeCalls++;
69
+ return _subscribe.apply(this, arguments);
70
+ };
71
+ var received = [];
72
+ var finished = 0;
73
+ function onSubscribe(err, stream) {
74
+ if (err) return done(err);
75
+ var messages = [];
76
+ received.push(messages);
77
+ stream.on('data', function(data) {
78
+ messages.push(data.n);
79
+ if (data.n !== 2 || ++finished < 2) return;
80
+ expect(received).eql([[1, 2], [1, 2]]);
81
+ done();
82
+ });
83
+ if (received.length < 2) return;
84
+ expect(subscribeCalls).equal(1);
85
+ pubsub.publish(['x'], {n: 1});
86
+ pubsub.publish(['x'], {n: 2});
87
+ }
88
+ pubsub.subscribe('x', onSubscribe);
89
+ pubsub.subscribe('x', onSubscribe);
90
+ });
91
+
92
+ it('keeps a channel subscribed when a concurrent subscriber destroys its stream', function(done) {
93
+ var pubsub = this.pubsub;
94
+ pubsub.subscribe('x', function(err, stream) {
95
+ if (err) return done(err);
96
+ stream.destroy();
97
+ });
98
+ pubsub.subscribe('x', function(err, stream) {
99
+ if (err) return done(err);
100
+ stream.on('data', function(data) {
101
+ expect(data).eql({test: true});
102
+ done();
103
+ });
104
+ pubsub.publish(['x'], {test: true});
105
+ });
106
+ });
107
+
63
108
  it('stream.destroy() unsubscribes from a channel', function(done) {
64
109
  var pubsub = this.pubsub;
65
110
  pubsub.subscribe('x', function(err, stream) {