sharedb 6.0.2 → 6.0.4
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/README.md +2 -2
- package/lib/agent.js +7 -0
- package/lib/backend.js +1 -4
- package/lib/client/doc.js +15 -14
- package/lib/client/query.js +1 -2
- package/lib/ot.js +33 -12
- package/lib/projections.js +7 -3
- package/lib/util.js +8 -4
- package/package.json +2 -2
- package/test/client/doc.js +175 -0
- package/test/client/snapshot-timestamp-request.js +20 -0
- package/test/client/snapshot-version-request.js +20 -0
- package/test/lib-util.js +56 -0
- package/test/ot.js +111 -0
- package/test/projections.js +29 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ShareDB
|
|
2
2
|
|
|
3
3
|
[](https://npmjs.org/package/sharedb)
|
|
4
|
-

|
|
5
|
+
[](https://coveralls.io/github/share/sharedb?branch=main)
|
|
6
6
|
|
|
7
7
|
ShareDB is a realtime database backend based on [Operational Transformation
|
|
8
8
|
(OT)](https://en.wikipedia.org/wiki/Operational_transformation) of JSON
|
package/lib/agent.js
CHANGED
|
@@ -388,6 +388,8 @@ Agent.prototype._checkRequest = function(request) {
|
|
|
388
388
|
request.a === ACTIONS.fetch ||
|
|
389
389
|
request.a === ACTIONS.subscribe ||
|
|
390
390
|
request.a === ACTIONS.unsubscribe ||
|
|
391
|
+
request.a === ACTIONS.snapshotFetch ||
|
|
392
|
+
request.a === ACTIONS.snapshotFetchByTimestamp ||
|
|
391
393
|
request.a === ACTIONS.presence) {
|
|
392
394
|
// Doc-based request.
|
|
393
395
|
if (request.c != null) {
|
|
@@ -401,6 +403,11 @@ Agent.prototype._checkRequest = function(request) {
|
|
|
401
403
|
}
|
|
402
404
|
}
|
|
403
405
|
|
|
406
|
+
if (request.a === ACTIONS.snapshotFetch || request.a === ACTIONS.snapshotFetchByTimestamp) {
|
|
407
|
+
if (request.c == null) return 'Invalid collection';
|
|
408
|
+
if (request.d == null) return 'Invalid id';
|
|
409
|
+
}
|
|
410
|
+
|
|
404
411
|
if (request.a === ACTIONS.op || request.a === ACTIONS.presence) {
|
|
405
412
|
if (request.v != null && (typeof request.v !== 'number' || request.v < 0)) return 'Invalid version';
|
|
406
413
|
}
|
package/lib/backend.js
CHANGED
|
@@ -106,10 +106,7 @@ Backend.prototype.close = function(callback) {
|
|
|
106
106
|
var wait = 4;
|
|
107
107
|
var backend = this;
|
|
108
108
|
function finish(err) {
|
|
109
|
-
if (err)
|
|
110
|
-
if (callback) return callback(err);
|
|
111
|
-
return backend.emit('error', err);
|
|
112
|
-
}
|
|
109
|
+
if (err) return util.callbackOrEmit(backend, err, callback);
|
|
113
110
|
if (--wait) return;
|
|
114
111
|
if (callback) callback();
|
|
115
112
|
}
|
package/lib/client/doc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
var emitter = require('../emitter');
|
|
2
2
|
var logger = require('../logger');
|
|
3
|
+
var ot = require('../ot');
|
|
3
4
|
var ShareDBError = require('../error');
|
|
4
5
|
var types = require('../types');
|
|
5
6
|
var util = require('../util');
|
|
@@ -130,10 +131,7 @@ Doc.prototype.destroy = function(callback) {
|
|
|
130
131
|
doc.whenNothingPending(function() {
|
|
131
132
|
if (doc.wantSubscribe) {
|
|
132
133
|
doc.unsubscribe(function(err) {
|
|
133
|
-
if (err)
|
|
134
|
-
if (callback) return callback(err);
|
|
135
|
-
return doc.emit('error', err);
|
|
136
|
-
}
|
|
134
|
+
if (err) return util.callbackOrEmit(doc, err, callback);
|
|
137
135
|
doc.connection._destroyDoc(doc);
|
|
138
136
|
if (callback) callback();
|
|
139
137
|
});
|
|
@@ -190,8 +188,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) {
|
|
|
190
188
|
ERROR_CODE.ERR_INGESTED_SNAPSHOT_HAS_NO_VERSION,
|
|
191
189
|
'Missing version in ingested snapshot. ' + this.collection + '.' + this.id
|
|
192
190
|
);
|
|
193
|
-
|
|
194
|
-
return this.emit('error', err);
|
|
191
|
+
return util.callbackOrEmit(this, err, callback);
|
|
195
192
|
}
|
|
196
193
|
|
|
197
194
|
// If the doc is already created or there are ops pending, we cannot use the
|
|
@@ -213,8 +210,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) {
|
|
|
213
210
|
ERROR_CODE.ERR_DOC_MISSING_VERSION,
|
|
214
211
|
'Cannot ingest snapshot in doc with null version. ' + this.collection + '.' + this.id
|
|
215
212
|
);
|
|
216
|
-
|
|
217
|
-
return this.emit('error', err);
|
|
213
|
+
return util.callbackOrEmit(this, err, callback);
|
|
218
214
|
}
|
|
219
215
|
// If we got a snapshot for a version further along than the document is
|
|
220
216
|
// currently, issue a fetch to get the latest ops and catch us up
|
|
@@ -599,6 +595,9 @@ Doc.prototype._otApply = function(op, source) {
|
|
|
599
595
|
);
|
|
600
596
|
}
|
|
601
597
|
|
|
598
|
+
var pathError = ot.checkOpPathsForType(this.type, op);
|
|
599
|
+
if (pathError) throw pathError;
|
|
600
|
+
|
|
602
601
|
// NB: If we need to add another argument to this event, we should consider
|
|
603
602
|
// the fact that the 'op' event has op.src as its 3rd argument
|
|
604
603
|
this.emit('before op batch', op.op, source);
|
|
@@ -760,11 +759,15 @@ Doc.prototype._submit = function(op, source, callback) {
|
|
|
760
759
|
);
|
|
761
760
|
}
|
|
762
761
|
|
|
763
|
-
|
|
764
|
-
return this.emit('error', err);
|
|
762
|
+
return util.callbackOrEmit(this, err, callback);
|
|
765
763
|
}
|
|
766
764
|
// Try to normalize the op. This removes trailing skip:0's and things like that.
|
|
767
765
|
if (this.type.normalize) op.op = this.type.normalize(op.op);
|
|
766
|
+
|
|
767
|
+
// This has to happen before _pushOp(), because _tryCompose() applies the op
|
|
768
|
+
// to a pending create, well before _otApply() gets a chance to check it
|
|
769
|
+
var pathError = ot.checkOpPathsForType(this.type, op);
|
|
770
|
+
if (pathError) return util.callbackOrEmit(this, pathError, callback);
|
|
768
771
|
}
|
|
769
772
|
|
|
770
773
|
try {
|
|
@@ -901,8 +904,7 @@ Doc.prototype.create = function(data, type, options, callback) {
|
|
|
901
904
|
}
|
|
902
905
|
if (this.type) {
|
|
903
906
|
var err = new ShareDBError(ERROR_CODE.ERR_DOC_ALREADY_CREATED, 'Document already exists');
|
|
904
|
-
|
|
905
|
-
return this.emit('error', err);
|
|
907
|
+
return util.callbackOrEmit(this, err, callback);
|
|
906
908
|
}
|
|
907
909
|
var op = {create: {type: type, data: data}};
|
|
908
910
|
var source = options && options.source;
|
|
@@ -923,8 +925,7 @@ Doc.prototype.del = function(options, callback) {
|
|
|
923
925
|
}
|
|
924
926
|
if (!this.type) {
|
|
925
927
|
var err = new ShareDBError(ERROR_CODE.ERR_DOC_DOES_NOT_EXIST, 'Document does not exist');
|
|
926
|
-
|
|
927
|
-
return this.emit('error', err);
|
|
928
|
+
return util.callbackOrEmit(this, err, callback);
|
|
928
929
|
}
|
|
929
930
|
var op = {del: true};
|
|
930
931
|
var source = options && options.source;
|
package/lib/client/query.js
CHANGED
|
@@ -149,8 +149,7 @@ Query.prototype._finishResponse = function(err, callback) {
|
|
|
149
149
|
this.ready = true;
|
|
150
150
|
if (err) {
|
|
151
151
|
this.connection._destroyQuery(this);
|
|
152
|
-
|
|
153
|
-
return this.emit('error', err);
|
|
152
|
+
return util.callbackOrEmit(this, err, callback);
|
|
154
153
|
}
|
|
155
154
|
if (callback) callback(null, this.results, this.extra);
|
|
156
155
|
};
|
package/lib/ot.js
CHANGED
|
@@ -108,18 +108,9 @@ function applyOpEdit(snapshot, edit) {
|
|
|
108
108
|
var type = types.map[snapshot.type];
|
|
109
109
|
if (!type) return new ShareDBError(ERROR_CODE.ERR_DOC_TYPE_NOT_RECOGNIZED, 'Unknown type');
|
|
110
110
|
|
|
111
|
-
if (type.name === 'json0'
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (Array.isArray(opComponent.p)) {
|
|
115
|
-
for (var j = 0; j < opComponent.p.length; j++) {
|
|
116
|
-
var pathSegment = opComponent.p[j];
|
|
117
|
-
if (util.isDangerousProperty(pathSegment)) {
|
|
118
|
-
return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
111
|
+
if (type.name === 'json0') {
|
|
112
|
+
var pathError = checkJson0OpPaths(edit);
|
|
113
|
+
if (pathError) return pathError;
|
|
123
114
|
}
|
|
124
115
|
|
|
125
116
|
try {
|
|
@@ -129,6 +120,32 @@ function applyOpEdit(snapshot, edit) {
|
|
|
129
120
|
}
|
|
130
121
|
}
|
|
131
122
|
|
|
123
|
+
// ot-json0 walks ops with .length and numeric indexing, so it treats array-like
|
|
124
|
+
// objects as ops too, and it coerces .length. This traversal has to match it
|
|
125
|
+
// exactly: anything ot-json0 will apply has to be checked here.
|
|
126
|
+
// See GHSA-9rqw-j2q5-gg2g
|
|
127
|
+
function checkJson0OpPaths(op) {
|
|
128
|
+
if (op == null) return;
|
|
129
|
+
for (var i = 0; i < op.length; i++) {
|
|
130
|
+
var component = op[i];
|
|
131
|
+
// ot-json0's checkValidOp() rejects the whole op before applying any of it,
|
|
132
|
+
// so there is nothing beyond this component left to check
|
|
133
|
+
if (!component || typeof component !== 'object' || !Array.isArray(component.p)) return;
|
|
134
|
+
for (var j = 0; j < component.p.length; j++) {
|
|
135
|
+
if (util.isDangerousProperty(component.p[j])) {
|
|
136
|
+
return new ShareDBError(ERROR_CODE.ERR_OT_OP_NOT_APPLIED, 'Invalid path segment');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
exports.checkOpPathsForType = function(type, op) {
|
|
143
|
+
if (!('op' in op)) return;
|
|
144
|
+
if (typeof type === 'string') type = types.map[type];
|
|
145
|
+
if (!type || type.name !== 'json0') return;
|
|
146
|
+
return checkJson0OpPaths(op.op);
|
|
147
|
+
};
|
|
148
|
+
|
|
132
149
|
exports.transform = function(type, op, appliedOp) {
|
|
133
150
|
// There are 16 cases this function needs to deal with - which are all the
|
|
134
151
|
// combinations of create/delete/op/noop from both op and appliedOp
|
|
@@ -178,6 +195,10 @@ exports.applyOps = function(snapshot, ops, options) {
|
|
|
178
195
|
options = options || {};
|
|
179
196
|
for (var index = 0; index < ops.length; index++) {
|
|
180
197
|
var op = ops[index];
|
|
198
|
+
// normalizeLegacyJson0Ops() applies op components itself, so paths have to
|
|
199
|
+
// be checked before it runs, not just in exports.apply()
|
|
200
|
+
var pathError = exports.checkOpPathsForType(snapshot.type, op);
|
|
201
|
+
if (pathError) return pathError;
|
|
181
202
|
if (options._normalizeLegacyJson0Ops) {
|
|
182
203
|
try {
|
|
183
204
|
normalizeLegacyJson0Ops(snapshot, op);
|
package/lib/projections.js
CHANGED
|
@@ -58,7 +58,7 @@ function projectEdit(fields, op) {
|
|
|
58
58
|
}
|
|
59
59
|
} else {
|
|
60
60
|
// The path has a first element. Just check it against the fields.
|
|
61
|
-
if (fields
|
|
61
|
+
if (isFieldAllowed(fields, path[0])) {
|
|
62
62
|
result.push(c);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -91,7 +91,7 @@ function isSnapshotAllowed(fields, snapshot) {
|
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
93
|
for (var k in snapshot.data) {
|
|
94
|
-
if (!fields
|
|
94
|
+
if (!isFieldAllowed(fields, k)) return false;
|
|
95
95
|
}
|
|
96
96
|
return true;
|
|
97
97
|
}
|
|
@@ -101,13 +101,17 @@ function isEditAllowed(fields, op) {
|
|
|
101
101
|
var c = op[i];
|
|
102
102
|
if (c.p.length === 0) {
|
|
103
103
|
return false;
|
|
104
|
-
} else if (!fields
|
|
104
|
+
} else if (!isFieldAllowed(fields, c.p[0])) {
|
|
105
105
|
return false;
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
return true;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function isFieldAllowed(fields, field) {
|
|
112
|
+
return util.hasOwn(fields, field) && !!fields[field];
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
function projectData(fields, data) {
|
|
112
116
|
// Return back null or undefined
|
|
113
117
|
if (data == null) {
|
package/lib/util.js
CHANGED
|
@@ -84,6 +84,11 @@ exports.callEach = function(callbacks, error) {
|
|
|
84
84
|
return called;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
exports.callbackOrEmit = function(emitter, error, callback) {
|
|
88
|
+
if (callback) return callback(error);
|
|
89
|
+
if (error) emitter.emit('error', error);
|
|
90
|
+
};
|
|
91
|
+
|
|
87
92
|
exports.truthy = function(arg) {
|
|
88
93
|
return !!arg;
|
|
89
94
|
};
|
|
@@ -100,14 +105,13 @@ exports.clone = function(obj) {
|
|
|
100
105
|
return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj));
|
|
101
106
|
};
|
|
102
107
|
|
|
108
|
+
// A null prototype makes '__proto__' an ordinary own key, so it can be in the map
|
|
103
109
|
var objectProtoPropNames = Object.create(null);
|
|
104
110
|
Object.getOwnPropertyNames(Object.prototype).forEach(function(prop) {
|
|
105
|
-
|
|
106
|
-
objectProtoPropNames[prop] = true;
|
|
107
|
-
}
|
|
111
|
+
objectProtoPropNames[prop] = true;
|
|
108
112
|
});
|
|
109
113
|
exports.isDangerousProperty = function(propName) {
|
|
110
|
-
return propName ===
|
|
114
|
+
return objectProtoPropNames[propName] === true;
|
|
111
115
|
};
|
|
112
116
|
|
|
113
117
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sharedb",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.4",
|
|
4
4
|
"description": "JSON OT database backend",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"dependencies": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"chai": "^6.2.2",
|
|
16
16
|
"eslint": "^10.1.0",
|
|
17
17
|
"eslint-config-google": "^0.14.0",
|
|
18
|
-
"mocha": "^
|
|
18
|
+
"mocha": "^12.0.0",
|
|
19
19
|
"nyc": "^18.0.0",
|
|
20
20
|
"ot-json0-v2": "https://github.com/ottypes/json0#90a3ae26364c4fa3b19b6df34dad46707a704421",
|
|
21
21
|
"ot-json1": "^1.0.2",
|
package/test/client/doc.js
CHANGED
|
@@ -682,6 +682,10 @@ describe('Doc', function() {
|
|
|
682
682
|
});
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
+
afterEach(function() {
|
|
686
|
+
delete Object.prototype.polluted;
|
|
687
|
+
});
|
|
688
|
+
|
|
685
689
|
['__proto__', 'constructor'].forEach(function(badProp) {
|
|
686
690
|
it('Rejects ops with collection ' + badProp, function(done) {
|
|
687
691
|
var collectionName = badProp;
|
|
@@ -755,6 +759,177 @@ describe('Doc', function() {
|
|
|
755
759
|
});
|
|
756
760
|
});
|
|
757
761
|
});
|
|
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
|
+
[
|
|
766
|
+
{
|
|
767
|
+
name: 'an array-like op',
|
|
768
|
+
op: {0: {p: ['__proto__', 'polluted'], oi: 'oops'}, length: 1}
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
name: 'ops with a path segment that is not a string',
|
|
772
|
+
op: [{p: [['__proto__'], 'polluted'], oi: 'oops'}]
|
|
773
|
+
}
|
|
774
|
+
].forEach(function(test) {
|
|
775
|
+
it('Rejects ' + test.name, function(done) {
|
|
776
|
+
var connection = this.connection;
|
|
777
|
+
var collectionName = 'test-collection';
|
|
778
|
+
var docId = 'test-doc';
|
|
779
|
+
connection.get(collectionName, docId).create({id: docId}, function(err) {
|
|
780
|
+
if (err) {
|
|
781
|
+
return done(err);
|
|
782
|
+
}
|
|
783
|
+
expectReceiveError(connection, collectionName, docId, 'Invalid path segment', function(error) {
|
|
784
|
+
if (error) {
|
|
785
|
+
return done(error);
|
|
786
|
+
}
|
|
787
|
+
expect({}.polluted).to.equal(undefined);
|
|
788
|
+
done();
|
|
789
|
+
});
|
|
790
|
+
connection.send({
|
|
791
|
+
a: 'op',
|
|
792
|
+
c: collectionName,
|
|
793
|
+
d: docId,
|
|
794
|
+
v: 1,
|
|
795
|
+
seq: connection.seq++,
|
|
796
|
+
x: {},
|
|
797
|
+
op: test.op
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
function expectInvalidPathSegment(error) {
|
|
804
|
+
expect(error.code).to.equal(ShareDBError.CODES.ERR_OT_OP_NOT_APPLIED);
|
|
805
|
+
expect(error.message).to.equal('Invalid path segment');
|
|
806
|
+
expect({}.polluted).to.equal(undefined);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
describe('ops from the server', function() {
|
|
810
|
+
var multiComponentOp = [{p: ['baz'], oi: true}, {p: ['__proto__', 'polluted'], oi: 'oops'}];
|
|
811
|
+
var connection;
|
|
812
|
+
var doc;
|
|
813
|
+
|
|
814
|
+
beforeEach(function(done) {
|
|
815
|
+
connection = this.connection;
|
|
816
|
+
doc = connection.get('test-collection', 'test-doc');
|
|
817
|
+
doc.create({foo: 'bar'}, done);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
function sendOp(op) {
|
|
821
|
+
connection.handleMessage({
|
|
822
|
+
a: 'op',
|
|
823
|
+
c: doc.collection,
|
|
824
|
+
d: doc.id,
|
|
825
|
+
v: 1,
|
|
826
|
+
src: 'hostile',
|
|
827
|
+
seq: 1,
|
|
828
|
+
op: op
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
[
|
|
833
|
+
{
|
|
834
|
+
name: 'ops with a dangerous first path segment',
|
|
835
|
+
op: [{p: ['__proto__', 'polluted'], oi: 'oops'}]
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: 'ops with a dangerous later path segment',
|
|
839
|
+
op: [{p: ['foo', 'constructor'], oi: 'oops'}]
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
name: 'an array-like op',
|
|
843
|
+
op: {0: {p: ['__proto__', 'polluted'], oi: 'oops'}, length: 1}
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
name: 'ops with a path segment that is not a string',
|
|
847
|
+
op: [{p: [['__proto__'], 'polluted'], oi: 'oops'}]
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
name: 'multi-component ops',
|
|
851
|
+
op: multiComponentOp
|
|
852
|
+
}
|
|
853
|
+
].forEach(function(test) {
|
|
854
|
+
it('Rejects ' + test.name, function(done) {
|
|
855
|
+
doc.on('error', function(error) {
|
|
856
|
+
expectInvalidPathSegment(error);
|
|
857
|
+
done();
|
|
858
|
+
});
|
|
859
|
+
sendOp(test.op);
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('does not apply the valid components of a rejected multi-component op', function(done) {
|
|
864
|
+
doc.on('error', function() {
|
|
865
|
+
expect(doc.data).to.not.have.property('baz');
|
|
866
|
+
done();
|
|
867
|
+
});
|
|
868
|
+
sendOp(multiComponentOp);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('leaves the doc usable after rejecting an op', function(done) {
|
|
872
|
+
doc.on('error', function() {
|
|
873
|
+
async.series([
|
|
874
|
+
doc.submitOp.bind(doc, [{p: ['baz'], oi: true}]),
|
|
875
|
+
doc.whenNothingPending.bind(doc),
|
|
876
|
+
function(next) {
|
|
877
|
+
expect(doc.data).to.eql({foo: 'bar', baz: true});
|
|
878
|
+
next();
|
|
879
|
+
}
|
|
880
|
+
], done);
|
|
881
|
+
});
|
|
882
|
+
sendOp(multiComponentOp);
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
describe('locally submitted ops', function() {
|
|
887
|
+
var badOp = [{p: ['__proto__', 'polluted'], oi: 'oops'}];
|
|
888
|
+
|
|
889
|
+
it('rejects an op composed into a pending create', function(done) {
|
|
890
|
+
var doc = this.connection.get('test-collection', 'test-doc');
|
|
891
|
+
doc.create({foo: 'bar'});
|
|
892
|
+
doc.submitOp(badOp, function(error) {
|
|
893
|
+
expectInvalidPathSegment(error);
|
|
894
|
+
done();
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('rejects an op without sending it to the server', function(done) {
|
|
899
|
+
var doc = this.connection.get('test-collection', 'test-doc');
|
|
900
|
+
doc.create({foo: 'bar'}, function(error) {
|
|
901
|
+
if (error) return done(error);
|
|
902
|
+
var calledBack = false;
|
|
903
|
+
doc.submitOp(badOp, function(error) {
|
|
904
|
+
calledBack = true;
|
|
905
|
+
expectInvalidPathSegment(error);
|
|
906
|
+
});
|
|
907
|
+
// The server would only reject asynchronously, so calling back
|
|
908
|
+
// synchronously is how we know the op never left the client
|
|
909
|
+
expect(calledBack).to.equal(true);
|
|
910
|
+
done();
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('leaves the doc usable after rejecting an op', function(done) {
|
|
915
|
+
var doc = this.connection.get('test-collection', 'test-doc');
|
|
916
|
+
async.series([
|
|
917
|
+
doc.create.bind(doc, {foo: 'bar'}),
|
|
918
|
+
function(next) {
|
|
919
|
+
doc.submitOp(badOp, function(error) {
|
|
920
|
+
expect(error).to.be.instanceOf(Error);
|
|
921
|
+
next();
|
|
922
|
+
});
|
|
923
|
+
},
|
|
924
|
+
doc.submitOp.bind(doc, [{p: ['baz'], oi: true}]),
|
|
925
|
+
doc.whenNothingPending.bind(doc),
|
|
926
|
+
function(next) {
|
|
927
|
+
expect(doc.data).to.eql({foo: 'bar', baz: true});
|
|
928
|
+
next();
|
|
929
|
+
}
|
|
930
|
+
], done);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
758
933
|
});
|
|
759
934
|
|
|
760
935
|
describe('toSnapshot', function() {
|
|
@@ -208,6 +208,26 @@ describe('SnapshotTimestampRequest', function() {
|
|
|
208
208
|
expect(fetch).to.throw(Error);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
[{}, null, '__proto__'].forEach(function(badValue) {
|
|
212
|
+
it('errors without reading the database if the collection is ' + JSON.stringify(badValue), function(done) {
|
|
213
|
+
var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
|
|
214
|
+
backend.connect().fetchSnapshotByTimestamp(badValue, 'time-machine', null, function(error) {
|
|
215
|
+
expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid collection'});
|
|
216
|
+
expect(getSnapshot).not.to.have.been.called;
|
|
217
|
+
done();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('errors without reading the database if the id is ' + JSON.stringify(badValue), function(done) {
|
|
222
|
+
var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
|
|
223
|
+
backend.connect().fetchSnapshotByTimestamp('books', badValue, null, function(error) {
|
|
224
|
+
expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid id'});
|
|
225
|
+
expect(getSnapshot).not.to.have.been.called;
|
|
226
|
+
done();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
211
231
|
it('returns an empty snapshot if trying to fetch a non-existent document', function(done) {
|
|
212
232
|
backend.connect().fetchSnapshotByTimestamp('books', 'does-not-exist', day1, function(error, snapshot) {
|
|
213
233
|
if (error) return done(error);
|
|
@@ -143,6 +143,26 @@ describe('SnapshotVersionRequest', function() {
|
|
|
143
143
|
expect(fetch).to.throw(Error);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
[{}, null, '__proto__'].forEach(function(badValue) {
|
|
147
|
+
it('errors without reading the database if the collection is ' + JSON.stringify(badValue), function(done) {
|
|
148
|
+
var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
|
|
149
|
+
backend.connect().fetchSnapshot(badValue, 'don-quixote', null, function(error) {
|
|
150
|
+
expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid collection'});
|
|
151
|
+
expect(getSnapshot).not.to.have.been.called;
|
|
152
|
+
done();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('errors without reading the database if the id is ' + JSON.stringify(badValue), function(done) {
|
|
157
|
+
var getSnapshot = sinon.spy(MemoryDb.prototype, 'getSnapshot');
|
|
158
|
+
backend.connect().fetchSnapshot('books', badValue, null, function(error) {
|
|
159
|
+
expect(error).to.include({code: 'ERR_MESSAGE_BADLY_FORMED', message: 'Invalid id'});
|
|
160
|
+
expect(getSnapshot).not.to.have.been.called;
|
|
161
|
+
done();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
146
166
|
it('errors if asking for a version that does not exist', function(done) {
|
|
147
167
|
backend.connect().fetchSnapshot('books', 'don-quixote', 4, function(error, snapshot) {
|
|
148
168
|
expect(error).to.be.ok;
|
package/test/lib-util.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
var EventEmitter = require('../lib/emitter').EventEmitter;
|
|
2
|
+
var util = require('../lib/util');
|
|
3
|
+
var expect = require('chai').expect;
|
|
4
|
+
|
|
5
|
+
describe('util.callbackOrEmit', function() {
|
|
6
|
+
var emitter;
|
|
7
|
+
var error;
|
|
8
|
+
|
|
9
|
+
beforeEach(function() {
|
|
10
|
+
emitter = new EventEmitter();
|
|
11
|
+
error = new Error('some error');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('calls back synchronously with the error', function() {
|
|
15
|
+
var calledWith = 'not called';
|
|
16
|
+
util.callbackOrEmit(emitter, error, function(err) {
|
|
17
|
+
calledWith = err;
|
|
18
|
+
});
|
|
19
|
+
expect(calledWith).to.equal(error);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('calls back synchronously when there is no error', function() {
|
|
23
|
+
var calledWith = 'not called';
|
|
24
|
+
util.callbackOrEmit(emitter, null, function(err) {
|
|
25
|
+
calledWith = err;
|
|
26
|
+
});
|
|
27
|
+
expect(calledWith).to.equal(null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not emit when a callback is provided', function() {
|
|
31
|
+
var emitted = false;
|
|
32
|
+
emitter.on('error', function() {
|
|
33
|
+
emitted = true;
|
|
34
|
+
});
|
|
35
|
+
util.callbackOrEmit(emitter, error, function() {});
|
|
36
|
+
expect(emitted).to.equal(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('emits the error when there is no callback', function() {
|
|
40
|
+
var emittedWith = 'not emitted';
|
|
41
|
+
emitter.on('error', function(err) {
|
|
42
|
+
emittedWith = err;
|
|
43
|
+
});
|
|
44
|
+
util.callbackOrEmit(emitter, error);
|
|
45
|
+
expect(emittedWith).to.equal(error);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does nothing when there is no error and no callback', function() {
|
|
49
|
+
var emitted = false;
|
|
50
|
+
emitter.on('error', function() {
|
|
51
|
+
emitted = true;
|
|
52
|
+
});
|
|
53
|
+
util.callbackOrEmit(emitter, null);
|
|
54
|
+
expect(emitted).to.equal(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/test/ot.js
CHANGED
|
@@ -125,6 +125,68 @@ describe('ot', function() {
|
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
describe('json0 op validation', function() {
|
|
129
|
+
var snapshot;
|
|
130
|
+
|
|
131
|
+
beforeEach(function() {
|
|
132
|
+
snapshot = {v: 6, type: type.uri, data: {colour: 'blue'}};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(function() {
|
|
136
|
+
delete Object.prototype.polluted;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not pollute the prototype through an array-like op', function() {
|
|
140
|
+
var op = {0: {p: ['__proto__', 'polluted'], oi: 'yes'}, length: 1};
|
|
141
|
+
var error = ot.apply(snapshot, {v: 6, op: op});
|
|
142
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
143
|
+
expect(error.message).to.equal('Invalid path segment');
|
|
144
|
+
expect({}.polluted).to.equal(undefined);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not pollute the prototype through an array-like op with a string length', function() {
|
|
148
|
+
var op = {0: {p: ['__proto__', 'polluted'], oi: 'yes'}, length: '1'};
|
|
149
|
+
var error = ot.apply(snapshot, {v: 6, op: op});
|
|
150
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
151
|
+
expect(error.message).to.equal('Invalid path segment');
|
|
152
|
+
expect({}.polluted).to.equal(undefined);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does not pollute the prototype through a path segment that is not a string', function() {
|
|
156
|
+
var error = ot.apply(snapshot, {v: 6, op: [{p: [['__proto__'], 'polluted'], oi: 'yes'}]});
|
|
157
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
158
|
+
expect(error.message).to.equal('Invalid path segment');
|
|
159
|
+
expect({}.polluted).to.equal(undefined);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
['__proto__', 'constructor'].forEach(function(badProp) {
|
|
163
|
+
it('rejects ' + badProp + ' as a path segment', function() {
|
|
164
|
+
var error = ot.apply(snapshot, {v: 6, op: [{p: [badProp, 'polluted'], oi: 'yes'}]});
|
|
165
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
166
|
+
expect(error.message).to.equal('Invalid path segment');
|
|
167
|
+
expect({}.polluted).to.equal(undefined);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns an error for an op component that is not an object', function() {
|
|
172
|
+
var error;
|
|
173
|
+
expect(function() {
|
|
174
|
+
error = ot.apply(snapshot, {v: 6, op: [null]});
|
|
175
|
+
}).to.not.throw();
|
|
176
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns an error for an array-like op whose components are missing', function() {
|
|
180
|
+
var error = ot.apply(snapshot, {v: 6, op: {length: 1e9}});
|
|
181
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('accepts an op that ot-json0 treats as a no-op', function() {
|
|
185
|
+
expect(ot.apply(snapshot, {v: 6, op: {p: ['colour'], oi: 'red'}})).equal();
|
|
186
|
+
expect(snapshot).to.eql({v: 7, type: type.uri, data: {colour: 'blue'}});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
128
190
|
describe('no-op', function() {
|
|
129
191
|
it('works on existing docs', function() {
|
|
130
192
|
var doc = {v: 6, type: type.uri, data: 'Hi'};
|
|
@@ -374,6 +436,55 @@ describe('ot', function() {
|
|
|
374
436
|
|
|
375
437
|
describe('applyOps', function() {
|
|
376
438
|
describe('with normalization turned on', function() {
|
|
439
|
+
afterEach(function() {
|
|
440
|
+
delete Object.prototype.polluted;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('does not pollute the prototype while normalizing a legacy op', function() {
|
|
444
|
+
var snapshot = {
|
|
445
|
+
type: 'http://sharejs.org/types/JSONv0',
|
|
446
|
+
data: {title: 'Wee Free Men'}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
var ops = [
|
|
450
|
+
{
|
|
451
|
+
v: 1,
|
|
452
|
+
op: [
|
|
453
|
+
{p: ['__proto__', 'polluted'], oi: 'yes'},
|
|
454
|
+
{p: ['title'], od: 'Wee Free Men', oi: 'Nation'}
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
var error = ot.applyOps(snapshot, ops, {
|
|
460
|
+
_normalizeLegacyJson0Ops: true
|
|
461
|
+
});
|
|
462
|
+
expect(error.code).to.equal(ERROR_CODE.ERR_OT_OP_NOT_APPLIED);
|
|
463
|
+
expect(error.message).to.equal('Invalid path segment');
|
|
464
|
+
expect({}.polluted).to.equal(undefined);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('replays a legacy op that ot-json0 treats as a no-op', function() {
|
|
468
|
+
var snapshot = {
|
|
469
|
+
type: 'http://sharejs.org/types/JSONv0',
|
|
470
|
+
data: {title: 'Wee Free Men'}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
var ops = [
|
|
474
|
+
{
|
|
475
|
+
v: 1,
|
|
476
|
+
op: {p: ['title'], od: 'Wee Free Men', oi: 'Nation'}
|
|
477
|
+
}
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
var error = ot.applyOps(snapshot, ops, {
|
|
481
|
+
_normalizeLegacyJson0Ops: true
|
|
482
|
+
});
|
|
483
|
+
expect(error).to.be.undefined;
|
|
484
|
+
expect(snapshot.data).to.eql({title: 'Wee Free Men'});
|
|
485
|
+
expect(snapshot.v).to.equal(2);
|
|
486
|
+
});
|
|
487
|
+
|
|
377
488
|
it('applies an op to a snapshot', function() {
|
|
378
489
|
var snapshot = {
|
|
379
490
|
type: 'http://sharejs.org/types/JSONv0',
|
package/test/projections.js
CHANGED
|
@@ -385,4 +385,33 @@ describe('projection utility methods', function() {
|
|
|
385
385
|
);
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
|
+
|
|
389
|
+
describe('non-truthy field values', function() {
|
|
390
|
+
it('does not treat a false field as a projected field', function() {
|
|
391
|
+
var op = {op: [{p: ['x', 'y'], oi: 'nope'}]};
|
|
392
|
+
expect(projections.isOpAllowed(null, {x: false}, op)).equal(false);
|
|
393
|
+
projections.projectOp({x: false}, op);
|
|
394
|
+
expect(op).eql({op: []});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('does not treat a false field as an allowed snapshot field', function() {
|
|
398
|
+
expect(projections.isSnapshotAllowed({x: false}, {type: type, data: {x: 1}})).equal(false);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('field names inherited from Object.prototype', function() {
|
|
403
|
+
['__proto__', 'constructor', 'toString'].forEach(function(badProp) {
|
|
404
|
+
it('does not treat ' + badProp + ' as a projected field', function() {
|
|
405
|
+
var op = {op: [{p: [badProp, 'x'], oi: 'oops'}]};
|
|
406
|
+
expect(projections.isOpAllowed(null, {x: true}, op)).equal(false);
|
|
407
|
+
projections.projectOp({x: true}, op);
|
|
408
|
+
expect(op).eql({op: []});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('does not treat ' + badProp + ' as an allowed snapshot field', function() {
|
|
412
|
+
var data = JSON.parse('{"' + badProp + '": "oops"}');
|
|
413
|
+
expect(projections.isSnapshotAllowed({x: true}, {type: type, data: data})).equal(false);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
388
417
|
});
|