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 +55 -29
- package/lib/backend.js +3 -0
- package/lib/client/doc.js +14 -1
- package/lib/client/presence/remote-doc-presence.js +7 -1
- package/lib/ot.js +39 -6
- package/lib/pubsub/index.js +15 -3
- package/lib/submit-request.js +6 -0
- package/package.json +1 -1
- package/test/agent.js +136 -0
- package/test/backend.js +34 -3
- package/test/client/doc.js +215 -26
- package/test/client/presence/doc-presence.js +54 -0
- package/test/client/query-subscribe.js +56 -18
- package/test/middleware.js +96 -0
- package/test/ot.js +46 -0
- package/test/pubsub-memory.js +45 -0
- package/test/pubsub.js +45 -0
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.
|
|
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.
|
|
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.
|
|
419
|
-
|
|
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 (
|
|
424
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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)
|
|
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 (!
|
|
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 (
|
|
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
|
|
265
|
+
if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
|
|
233
266
|
if (!util.supportsPresence(type)) {
|
|
234
|
-
return
|
|
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
|
|
281
|
+
return new ShareDBError(ERROR_CODE.ERR_PRESENCE_TRANSFORM_FAILED, error.message || error);
|
|
249
282
|
}
|
|
250
283
|
|
|
251
284
|
presence.v++;
|
package/lib/pubsub/index.js
CHANGED
|
@@ -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
|
-
|
|
87
|
+
delete pubsub._subscribing[channel];
|
|
88
|
+
if (err) return util.callEach(callbacks, err);
|
|
81
89
|
pubsub.subscribed[channel] = true;
|
|
82
|
-
var
|
|
83
|
-
|
|
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
|
|
package/lib/submit-request.js
CHANGED
|
@@ -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
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
|
});
|
package/test/client/doc.js
CHANGED
|
@@ -657,31 +657,31 @@ describe('Doc', function() {
|
|
|
657
657
|
});
|
|
658
658
|
});
|
|
659
659
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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('
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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,
|
|
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
|
-
|
|
947
|
+
null,
|
|
910
948
|
function(err) {
|
|
911
949
|
if (err) {
|
|
912
950
|
expect(err.message).to.be.equal('TEST_ERROR');
|
package/test/middleware.js
CHANGED
|
@@ -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'};
|
package/test/pubsub-memory.js
CHANGED
|
@@ -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) {
|