[ranges-tracker] add legacy orderedRejections field in tracked-changes (#36565)

* [docstore] use more realistic test stubs for ranges

* [monorepo] define ranges schemas in @overleaf/ranges-tracker

* [ranges-tracker] add legacy orderedRejections field in tracked-changes

* [monorepo] resolve @overleaf/ranges-tracker subpath imports in typescript

* [real-time] validate comment thread ids and tracked change id seeds

* [ranges-tracker] require ObjectId ids and tracked change metadata in ranges

* [monorepo] require ObjectId tracked change ids on the accept routes

* [web] use ObjectId comment thread ids in the DocumentController tests

GitOrigin-RevId: 58b20dec311d85b917ef63f9efbe745a265a2de6
This commit is contained in:
Jakob Ackermann
2026-09-17 12:01:19 +00:00
committed by Copybot
parent 833e44d142
commit 4e80d65efc
23 changed files with 501 additions and 383 deletions
+5 -5
View File
@@ -40,7 +40,10 @@ const applyOtUpdateSchema = z.strictObject({
lastV: z.number().optional(),
meta: z
.strictObject({
tc: z.string().optional(),
// the seed RangesTracker builds tracked-change ids from: the first 18
// characters of an ObjectId, leaving 6 for the increment part, see
// RangesTracker.generateIdSeed
tc: zz.hex().length(18).optional(),
})
.optional(),
op: z
@@ -62,10 +65,7 @@ const applyOtUpdateSchema = z.strictObject({
z.strictObject({
c: z.string(),
p: z.number().int().min(0),
// comment thread ids are usually ObjectIds, but legacy
// documents carry arbitrary strings (see docstore's ranges
// schema for the same caveat) -- kept as a plain string
t: z.string(),
t: zz.objectId(),
u: z.boolean().optional(),
})
)
@@ -340,7 +340,7 @@ describe('applyOtUpdate', function () {
describe('when authorized to read-only with a comment update', function () {
before(function (done) {
this.comment_update = {
op: [{ c: 'foo', p: 42, t: '1234' }],
op: [{ c: 'foo', p: 42, t: FixturesManager.getRandomId() }],
v: 42,
}
return async.series(
@@ -930,6 +930,201 @@ describe('applyOtUpdate', function () {
})
})
describe('when authorized with a tracked change id seed', function () {
before(function (done) {
// RangesTracker seeds are the first 18 characters of an ObjectId
this.id_seed = FixturesManager.getRandomId().slice(0, 18)
this.update = {
op: [{ i: 'foo', p: 42 }],
v: 42,
meta: { tc: this.id_seed },
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readAndWrite',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
cb
)
},
],
done
)
})
it('should push the project into the pending updates list', function (done) {
getPendingUpdatesList((error, ...rest) => {
if (error) return done(error)
const [projectId] = Array.from(rest[0])
projectId.should.equal(this.project_id)
return done()
})
return null
})
it('should push the update into redis with the id seed', function (done) {
rclient.lrange(
redisSettings.documentupdater.key_schema.pendingProjectUpdates({
project_id: this.project_id,
}),
0,
-1,
(error, ...rest) => {
if (error) return done(error)
let [update] = Array.from(rest[0])
update = JSON.parse(update)
update.op.should.deep.equal(this.update.op)
update.meta.should.include({
source: this.client.publicId,
user_id: this.user_id,
tc: this.id_seed,
})
return done()
}
)
return null
})
return after(function (done) {
return async.series(
[
cb => clearPendingUpdatesList(cb),
cb =>
rclient.del(
'DocsWithPendingUpdates',
`${this.project_id}:${this.doc_id}`,
cb
),
cb =>
rclient.del(
redisSettings.documentupdater.key_schema.pendingProjectUpdates({
project_id: this.project_id,
}),
cb
),
],
done
)
})
})
describe('when authorized with a malformed tracked change id seed', function () {
before(function (done) {
this.update = {
op: [{ i: 'foo', p: 42 }],
v: 42,
meta: { tc: 'not-an-id-seed' },
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readAndWrite',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
error => {
this.error = error
return cb()
}
)
},
],
done
)
})
it('should return an error', function () {
return expect(this.error).to.exist
})
it('should disconnect the client', function (done) {
return setTimeout(() => {
this.client.socket.connected.should.equal(false)
return done()
}, 300)
})
return it('should not put the update in redis', function (done) {
rclient.llen(
redisSettings.documentupdater.key_schema.pendingProjectUpdates({
project_id: this.project_id,
}),
(error, len) => {
if (error) return done(error)
len.should.equal(0)
return done()
}
)
return null
})
})
describe('when authorized with unknown fields', function () {
before(function (done) {
this.update = {