sharedb 6.0.1 → 6.0.3

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/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');
@@ -599,6 +600,9 @@ Doc.prototype._otApply = function(op, source) {
599
600
  );
600
601
  }
601
602
 
603
+ var pathError = ot.checkOpPathsForType(this.type, op);
604
+ if (pathError) throw pathError;
605
+
602
606
  // NB: If we need to add another argument to this event, we should consider
603
607
  // the fact that the 'op' event has op.src as its 3rd argument
604
608
  this.emit('before op batch', op.op, source);
@@ -765,6 +769,14 @@ Doc.prototype._submit = function(op, source, callback) {
765
769
  }
766
770
  // Try to normalize the op. This removes trailing skip:0's and things like that.
767
771
  if (this.type.normalize) op.op = this.type.normalize(op.op);
772
+
773
+ // This has to happen before _pushOp(), because _tryCompose() applies the op
774
+ // to a pending create, well before _otApply() gets a chance to check it
775
+ var pathError = ot.checkOpPathsForType(this.type, op);
776
+ if (pathError) {
777
+ if (callback) return callback(pathError);
778
+ return this.emit('error', pathError);
779
+ }
768
780
  }
769
781
 
770
782
  try {
@@ -968,11 +980,11 @@ Doc.prototype._opAcknowledged = function(message) {
968
980
  }
969
981
 
970
982
  if (message[ACTIONS.fixup]) {
971
- for (var i = 0; i < message[ACTIONS.fixup].length; i++) {
972
- var fixupOp = message[ACTIONS.fixup][i];
983
+ for (var fixupIndex = 0; fixupIndex < message[ACTIONS.fixup].length; fixupIndex++) {
984
+ var fixupOp = message[ACTIONS.fixup][fixupIndex];
973
985
 
974
- for (var j = 0; j < this.pendingOps.length; j++) {
975
- var transformErr = transformX(this.pendingOps[i], fixupOp);
986
+ for (var pendingIndex = 0; pendingIndex < this.pendingOps.length; pendingIndex++) {
987
+ var transformErr = transformX(this.pendingOps[pendingIndex], fixupOp);
976
988
  if (transformErr) return this._hardRollback(transformErr);
977
989
  }
978
990
 
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' && Array.isArray(edit)) {
112
- for (var i = 0; i < edit.length; i++) {
113
- var opComponent = edit[i];
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);
@@ -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[path[0]]) {
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[k]) return false;
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[c.p[0]]) {
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) {
@@ -3,6 +3,7 @@ var projections = require('./projections');
3
3
  var ShareDBError = require('./error');
4
4
  var types = require('./types');
5
5
  var protocol = require('./protocol');
6
+ var util = require('./util');
6
7
 
7
8
  var ERROR_CODE = ShareDBError.CODES;
8
9
 
@@ -43,6 +44,7 @@ function SubmitRequest(backend, agent, index, id, op, options) {
43
44
  this.ops = [];
44
45
  this.channels = null;
45
46
  this._fixupOps = [];
47
+ this._opBeforeFixups = null;
46
48
  }
47
49
  module.exports = SubmitRequest;
48
50
 
@@ -70,6 +72,12 @@ SubmitRequest.prototype.$fixup = function(op) {
70
72
  );
71
73
  }
72
74
 
75
+ if (!this._opBeforeFixups) {
76
+ this._opBeforeFixups = this.op.create ?
77
+ {data: util.clone(this.op.create.data)} :
78
+ {op: util.clone(this.op.op)};
79
+ }
80
+
73
81
  if (this.op.create) this.op.create.data = type.apply(this.op.create.data, op);
74
82
  else this.op.op = type.compose(this.op.op, op);
75
83
 
@@ -186,8 +194,6 @@ SubmitRequest.prototype.apply = function(callback) {
186
194
  // Always set the channels before each attempt to apply. If the channels are
187
195
  // modified in a middleware and we retry, we want to reset to a new array
188
196
  this.channels = this.backend.getChannels(this.collection, this.id);
189
- this._fixupOps = [];
190
- delete this.op.m.fixup;
191
197
 
192
198
  var request = this;
193
199
  this.backend.trigger(this.backend.MIDDLEWARE_ACTIONS.apply, this.agent, this, function(err) {
@@ -254,10 +260,21 @@ SubmitRequest.prototype.retry = function(callback) {
254
260
  if (this.maxRetries != null && this.retries > this.maxRetries) {
255
261
  return callback(this.maxRetriesError());
256
262
  }
263
+ this._resetFixups();
257
264
  this.backend.emit('timing', 'submit.retry', Date.now() - this.start, this);
258
265
  this.submit(callback);
259
266
  };
260
267
 
268
+ // The fixups must be undone before the op is transformed forward again
269
+ SubmitRequest.prototype._resetFixups = function() {
270
+ this._fixupOps = [];
271
+ delete this.op.m.fixup;
272
+ if (!this._opBeforeFixups) return;
273
+ if (this.op.create) this.op.create.data = this._opBeforeFixups.data;
274
+ else this.op.op = this._opBeforeFixups.op;
275
+ this._opBeforeFixups = null;
276
+ };
277
+
261
278
  SubmitRequest.prototype._transformOp = function(ops) {
262
279
  var type = this.snapshot.type;
263
280
  for (var i = 0; i < ops.length; i++) {
package/lib/util.js CHANGED
@@ -100,14 +100,13 @@ exports.clone = function(obj) {
100
100
  return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj));
101
101
  };
102
102
 
103
+ // A null prototype makes '__proto__' an ordinary own key, so it can be in the map
103
104
  var objectProtoPropNames = Object.create(null);
104
105
  Object.getOwnPropertyNames(Object.prototype).forEach(function(prop) {
105
- if (prop !== '__proto__') {
106
- objectProtoPropNames[prop] = true;
107
- }
106
+ objectProtoPropNames[prop] = true;
108
107
  });
109
108
  exports.isDangerousProperty = function(propName) {
110
- return propName === '__proto__' || objectProtoPropNames[propName];
109
+ return objectProtoPropNames[propName] === true;
111
110
  };
112
111
 
113
112
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sharedb",
3
- "version": "6.0.1",
3
+ "version": "6.0.3",
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": "^11.7.5",
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",
@@ -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() {
@@ -565,6 +565,68 @@ describe('middleware', function() {
565
565
  });
566
566
  });
567
567
 
568
+ it('transforms a pending op by multiple fixups', function(done) {
569
+ var applied = false;
570
+ backend.use('apply', function(request, next) {
571
+ if (applied) return next();
572
+ applied = true;
573
+ request.$fixup([{p: ['tricks', 0], li: 'sit'}]);
574
+ request.$fixup([{p: ['tricks', 0], li: 'stay'}]);
575
+ next();
576
+ });
577
+
578
+ var remoteConnection = backend.connect();
579
+ var remoteDoc = remoteConnection.get('dogs', 'fido');
580
+
581
+ remoteDoc.subscribe(function(error) {
582
+ if (error) return done(error);
583
+
584
+ expect(remoteDoc.data).to.eql({name: 'fido'});
585
+
586
+ remoteDoc.on('op batch', function() {
587
+ if (remoteDoc.version !== 3) return;
588
+ expect(remoteDoc.data.tricks).to.eql(['stay', 'sit', 'fetch', 'lie down']);
589
+ expect(remoteDoc.data).to.eql(doc.data);
590
+ done();
591
+ });
592
+
593
+ doc.preventCompose = true;
594
+ doc.submitOp([{p: ['tricks'], oi: ['fetch']}], errorHandler(done));
595
+ doc.submitOp([{p: ['tricks', 1], li: 'lie down'}], errorHandler(done));
596
+ });
597
+ });
598
+
599
+ it('transforms multiple pending ops by a fixup', function(done) {
600
+ var applied = false;
601
+ backend.use('apply', function(request, next) {
602
+ if (applied) return next();
603
+ applied = true;
604
+ request.$fixup([{p: ['tricks', 0], li: 'stay'}]);
605
+ next();
606
+ });
607
+
608
+ var remoteConnection = backend.connect();
609
+ var remoteDoc = remoteConnection.get('dogs', 'fido');
610
+
611
+ remoteDoc.subscribe(function(error) {
612
+ if (error) return done(error);
613
+
614
+ expect(remoteDoc.data).to.eql({name: 'fido'});
615
+
616
+ remoteDoc.on('op batch', function() {
617
+ if (remoteDoc.version !== 4) return;
618
+ expect(remoteDoc.data.tricks).to.eql(['stay', 'fetch', 'sit', 'roll over']);
619
+ expect(remoteDoc.data).to.eql(doc.data);
620
+ done();
621
+ });
622
+
623
+ doc.preventCompose = true;
624
+ doc.submitOp([{p: ['tricks'], oi: ['fetch']}], errorHandler(done));
625
+ doc.submitOp([{p: ['tricks', 1], li: 'sit'}], errorHandler(done));
626
+ doc.submitOp([{p: ['tricks', 2], li: 'roll over'}], errorHandler(done));
627
+ });
628
+ });
629
+
568
630
  it('applies a fixup to a creation op', function(done) {
569
631
  backend.use('apply', function(request, next) {
570
632
  request.$fixup([{p: ['goodBoy'], oi: true}]);
@@ -702,6 +764,79 @@ describe('middleware', function() {
702
764
  });
703
765
  });
704
766
 
767
+ it('applies the fixup once when the submit is retried', function(done) {
768
+ var remoteDoc = backend.connect().get('dogs', 'fido');
769
+
770
+ doc.submitOp([{p: ['fixups'], oi: 0}], function(error) {
771
+ if (error) return done(error);
772
+ remoteDoc.fetch(function(error) {
773
+ if (error) return done(error);
774
+
775
+ backend.use('apply', function(request, next) {
776
+ request.$fixup([{p: ['fixups'], na: 1}]);
777
+ next();
778
+ });
779
+
780
+ var commitDoc;
781
+ var commitRemoteDoc;
782
+ backend.use('commit', function(request, next) {
783
+ // Once we've forced the race, let the retry through untouched
784
+ if (commitDoc && commitRemoteDoc) return next();
785
+ if (request.op.src === doc.connection.id) commitDoc = next;
786
+ else commitRemoteDoc = next;
787
+ // Both ops have now been applied to the same snapshot, so
788
+ // committing one of them will force the other to retry
789
+ if (commitDoc && commitRemoteDoc) commitDoc();
790
+ });
791
+
792
+ doc.submitOp([{p: ['name', 0], si: 'a'}], function(error) {
793
+ if (error) return done(error);
794
+ commitRemoteDoc();
795
+ });
796
+
797
+ remoteDoc.submitOp([{p: ['name', 0], si: 'b'}], function(error) {
798
+ if (error) return done(error);
799
+ backend.db.getSnapshot('dogs', 'fido', null, null, function(error, snapshot) {
800
+ if (error) return done(error);
801
+ expect(snapshot.data.fixups).to.equal(2);
802
+ expect(snapshot.data).to.eql(remoteDoc.data);
803
+ expect(snapshot.v).to.equal(remoteDoc.version);
804
+ done();
805
+ });
806
+ });
807
+ });
808
+ });
809
+ });
810
+
811
+ it('applies the fixup once when a create is retried', function(done) {
812
+ backend.use('apply', function(request, next) {
813
+ request.$fixup([{p: ['fixups'], na: 1}]);
814
+ next();
815
+ });
816
+
817
+ // A create can only be retried if the database reports a failed commit
818
+ // without another op having landed, since a create that loses a genuine
819
+ // race is rejected during transform
820
+ var commit = backend.db.commit;
821
+ var lostRace = false;
822
+ sinon.stub(backend.db, 'commit').callsFake(function(collection, id, op, snapshot, options, callback) {
823
+ if (lostRace) return commit.call(this, collection, id, op, snapshot, options, callback);
824
+ lostRace = true;
825
+ process.nextTick(callback, null, false);
826
+ });
827
+
828
+ doc = connection.get('dogs', 'rover');
829
+ doc.create({name: 'rover', fixups: 0}, function(error) {
830
+ if (error) return done(error);
831
+ backend.db.getSnapshot('dogs', 'rover', null, null, function(error, snapshot) {
832
+ if (error) return done(error);
833
+ expect(snapshot.data.fixups).to.equal(1);
834
+ expect(snapshot.data).to.eql(doc.data);
835
+ done();
836
+ });
837
+ });
838
+ });
839
+
705
840
  it('applies two fixups', function(done) {
706
841
  backend.use('apply', function(request, next) {
707
842
  request.$fixup([{p: ['tricks', 0], li: 'sit'}]);
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',
@@ -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
  });