sharedb 6.0.4 → 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 +66 -29
- package/lib/backend.js +20 -6
- package/lib/client/doc.js +14 -1
- package/lib/client/presence/remote-doc-presence.js +7 -1
- package/lib/ot.js +53 -14
- package/lib/projections.js +6 -6
- package/lib/pubsub/index.js +15 -3
- package/lib/submit-request.js +6 -0
- package/package.json +1 -1
- package/test/agent.js +240 -0
- package/test/backend.js +86 -3
- package/test/client/doc.js +215 -26
- package/test/client/presence/doc-presence.js +54 -0
- package/test/client/presence/presence.js +48 -0
- package/test/client/query-subscribe.js +56 -18
- package/test/client/snapshot-timestamp-request.js +38 -1
- package/test/middleware.js +96 -0
- package/test/ot.js +62 -0
- package/test/projections.js +18 -0
- package/test/pubsub-memory.js +45 -0
- package/test/pubsub.js +45 -0
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();
|
|
@@ -83,4 +219,108 @@ describe('Agent', function() {
|
|
|
83
219
|
});
|
|
84
220
|
});
|
|
85
221
|
});
|
|
222
|
+
|
|
223
|
+
describe('pub/sub channel collisions', function() {
|
|
224
|
+
var writer;
|
|
225
|
+
var subscriber;
|
|
226
|
+
|
|
227
|
+
beforeEach(function() {
|
|
228
|
+
writer = backend.connect();
|
|
229
|
+
subscriber = backend.connect();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('does not deliver ops for a doc whose collection and id join to the same channel', function(done) {
|
|
233
|
+
var doc = subscriber.get('notes', 'private.alice');
|
|
234
|
+
doc.subscribe(function(error) {
|
|
235
|
+
if (error) return done(error);
|
|
236
|
+
doc.once('create', function() {
|
|
237
|
+
expect(doc.data).to.eql({text: 'Hello'});
|
|
238
|
+
done();
|
|
239
|
+
});
|
|
240
|
+
writer.get('notes.private', 'alice').create({diary: 'Dear diary'}, function(error) {
|
|
241
|
+
if (error) return done(error);
|
|
242
|
+
writer.get('notes', 'private.alice').create({text: 'Hello'}, function(error) {
|
|
243
|
+
if (error) done(error);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does not deliver ops from a collection whose channel matches the doc channel', function(done) {
|
|
250
|
+
var doc = subscriber.get('notes', 'private');
|
|
251
|
+
doc.subscribe(function(error) {
|
|
252
|
+
if (error) return done(error);
|
|
253
|
+
doc.once('create', function() {
|
|
254
|
+
expect(doc.data).to.eql({text: 'Hello'});
|
|
255
|
+
done();
|
|
256
|
+
});
|
|
257
|
+
writer.get('notes.private', 'alice').create({diary: 'Dear diary'}, function(error) {
|
|
258
|
+
if (error) return done(error);
|
|
259
|
+
writer.get('notes', 'private').create({text: 'Hello'}, function(error) {
|
|
260
|
+
if (error) done(error);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('does not deliver ops to a query on a collection whose channel matches the doc channel', function(done) {
|
|
267
|
+
var opMessages = [];
|
|
268
|
+
var diaryEdit = {p: ['diary'], od: 'Dear diary', oi: 'Dear diary, again'};
|
|
269
|
+
subscriber.on('receive', function(request) {
|
|
270
|
+
if (request.data.a === ACTIONS.op) opMessages.push(request.data);
|
|
271
|
+
});
|
|
272
|
+
var privateDoc = writer.get('notes.private', 'private');
|
|
273
|
+
privateDoc.create({diary: 'Dear diary'}, function(error) {
|
|
274
|
+
if (error) return done(error);
|
|
275
|
+
subscriber.createSubscribeQuery('notes.private', {}, null, function(error, results) {
|
|
276
|
+
if (error) return done(error);
|
|
277
|
+
results[0].once('op', function() {
|
|
278
|
+
expect(opMessages).to.have.length(1);
|
|
279
|
+
expect(opMessages[0].op).to.eql([diaryEdit]);
|
|
280
|
+
done();
|
|
281
|
+
});
|
|
282
|
+
writer.get('notes', 'private').create({text: 'Hello'}, function(error) {
|
|
283
|
+
if (error) return done(error);
|
|
284
|
+
privateDoc.submitOp(diaryEdit, function(error) {
|
|
285
|
+
if (error) done(error);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('does not deliver ops to a query from another collection sharing its custom channel', function(done) {
|
|
293
|
+
backend.use('commit', function(context, next) {
|
|
294
|
+
context.channels.push('shared');
|
|
295
|
+
next();
|
|
296
|
+
});
|
|
297
|
+
backend.use('query', function(context, next) {
|
|
298
|
+
context.channels = ['shared'];
|
|
299
|
+
next();
|
|
300
|
+
});
|
|
301
|
+
var opMessages = [];
|
|
302
|
+
var rename = {p: ['name'], od: 'Fido', oi: 'Rex'};
|
|
303
|
+
subscriber.on('receive', function(request) {
|
|
304
|
+
if (request.data.a === ACTIONS.op) opMessages.push(request.data);
|
|
305
|
+
});
|
|
306
|
+
var dog = writer.get('dogs', 'fido');
|
|
307
|
+
dog.create({name: 'Fido'}, function(error) {
|
|
308
|
+
if (error) return done(error);
|
|
309
|
+
subscriber.createSubscribeQuery('dogs', {}, null, function(error, results) {
|
|
310
|
+
if (error) return done(error);
|
|
311
|
+
results[0].once('op', function() {
|
|
312
|
+
expect(opMessages).to.have.length(1);
|
|
313
|
+
expect(opMessages[0].op).to.eql([rename]);
|
|
314
|
+
done();
|
|
315
|
+
});
|
|
316
|
+
writer.get('cats', 'fido').create({name: 'Tom'}, function(error) {
|
|
317
|
+
if (error) return done(error);
|
|
318
|
+
dog.submitOp(rename, function(error) {
|
|
319
|
+
if (error) done(error);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
86
326
|
});
|
package/test/backend.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
var Backend = require('../lib/backend');
|
|
2
|
+
var MemoryDB = require('../lib/db/memory');
|
|
2
3
|
var expect = require('chai').expect;
|
|
3
4
|
var sinon = require('sinon');
|
|
4
5
|
var logger = require('../lib/logger');
|
|
@@ -28,6 +29,46 @@ describe('Backend', function() {
|
|
|
28
29
|
expect(handler.callCount).to.equal(1);
|
|
29
30
|
});
|
|
30
31
|
});
|
|
32
|
+
|
|
33
|
+
describe('extraDbs', function() {
|
|
34
|
+
var archive;
|
|
35
|
+
|
|
36
|
+
beforeEach(function() {
|
|
37
|
+
archive = new MemoryDB();
|
|
38
|
+
backend = new Backend({extraDbs: {archive: archive}});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) {
|
|
42
|
+
it(method + ' queries the extra DB named by the db option', function(done) {
|
|
43
|
+
var query = sinon.spy(archive, 'query');
|
|
44
|
+
backend.connect()[method]('books', {}, {db: 'archive'}, function(error) {
|
|
45
|
+
if (error) return done(error);
|
|
46
|
+
expect(query).to.have.been.calledWith('books');
|
|
47
|
+
done();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
['constructor', '__proto__', {toString: 1}].forEach(function(badValue) {
|
|
52
|
+
it(method + ' errors if the db option is ' + JSON.stringify(badValue), function(done) {
|
|
53
|
+
backend.connect()[method]('books', {}, {db: badValue}, function(error) {
|
|
54
|
+
expect(error).to.include({code: 'ERR_DATABASE_ADAPTER_NOT_FOUND'});
|
|
55
|
+
done();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(method + ' errors if middleware sets the db option to ' + JSON.stringify(badValue), function(done) {
|
|
60
|
+
backend.use('query', function(request, next) {
|
|
61
|
+
request.options.db = badValue;
|
|
62
|
+
process.nextTick(next);
|
|
63
|
+
});
|
|
64
|
+
backend.connect()[method]('books', {}, null, function(error) {
|
|
65
|
+
expect(error).to.include({code: 'ERR_DATABASE_ADAPTER_NOT_FOUND'});
|
|
66
|
+
done();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
31
72
|
});
|
|
32
73
|
|
|
33
74
|
describe('a simple document', function() {
|
|
@@ -207,7 +248,7 @@ describe('Backend', function() {
|
|
|
207
248
|
title: '1984',
|
|
208
249
|
author: 'George Orwell'
|
|
209
250
|
});
|
|
210
|
-
var op = {op: {p: ['publication'], oi: 1949}};
|
|
251
|
+
var op = {op: [{p: ['publication'], oi: 1949}]};
|
|
211
252
|
stream.on('data', function(data) {
|
|
212
253
|
expect(data.op).to.eql(op.op);
|
|
213
254
|
done();
|
|
@@ -229,6 +270,48 @@ describe('Backend', function() {
|
|
|
229
270
|
});
|
|
230
271
|
});
|
|
231
272
|
|
|
273
|
+
describe('subscribeBulk', function() {
|
|
274
|
+
it('only subscribes once to a duplicated id', function(done) {
|
|
275
|
+
backend.subscribeBulk(agent, 'books', ['1984', '1984'], function(error, streams) {
|
|
276
|
+
if (error) return done(error);
|
|
277
|
+
expect(Object.keys(streams)).to.eql(['1984']);
|
|
278
|
+
expect(backend.pubsub.streamsCount).to.equal(1);
|
|
279
|
+
done();
|
|
280
|
+
});
|
|
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
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
232
315
|
describe('submitRequestEnd', function() {
|
|
233
316
|
it('emits after write', function(done) {
|
|
234
317
|
var afterWriteCalled = false;
|
|
@@ -245,7 +328,7 @@ describe('Backend', function() {
|
|
|
245
328
|
done();
|
|
246
329
|
});
|
|
247
330
|
|
|
248
|
-
var op = {op: {p: ['publicationYear'], oi: 1949}};
|
|
331
|
+
var op = {op: [{p: ['publicationYear'], oi: 1949}]};
|
|
249
332
|
backend.submit(agent, 'books', '1984', op, null, function(error) {
|
|
250
333
|
if (error) done(error);
|
|
251
334
|
});
|
|
@@ -262,7 +345,7 @@ describe('Backend', function() {
|
|
|
262
345
|
done();
|
|
263
346
|
});
|
|
264
347
|
|
|
265
|
-
var op = {op: {p: ['publicationYear'], oi: 1949}};
|
|
348
|
+
var op = {op: [{p: ['publicationYear'], oi: 1949}]};
|
|
266
349
|
backend.submit(agent, 'books', '1984', op, null, function() {
|
|
267
350
|
// Swallow the error
|
|
268
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) {
|