[real-time] adopt strict zod types and extend test coverage (#36031)

* [editor-core] extend coverage of zod schemas

* [real-time] adopt strict zod types and extend test coverage

* [project-history] make type check happy

* [web] make type check happy

GitOrigin-RevId: e1a65b9b3560b31ad2af817067a4b549865a5bb1
This commit is contained in:
Jakob Ackermann
2026-09-17 11:42:50 +00:00
committed by Copybot
parent 81df324bcc
commit f6bb5ce1bd
20 changed files with 1228 additions and 109 deletions
+13 -1
View File
@@ -20,6 +20,7 @@ import socketIOClient from 'socket.io-client'
import http from 'node:http'
import Router from './app/js/Router.js'
import WebsocketLoadBalancer from './app/js/WebsocketLoadBalancer.js'
import { z, parseReq, handleValidationError } from '@overleaf/validation-tools'
logger.initialize('real-time')
Metrics.event_loop.monitor(logger)
@@ -153,8 +154,17 @@ app.get('/status', function (req, res) {
}
})
const debugEventsSchema = z.object({
query: z.strictObject({
count: z.coerce.number().int().optional(),
}),
})
app.get('/debug/events', function (req, res) {
Settings.debugEvents = parseInt(req.query.count, 10) || 20
const {
query: { count },
} = parseReq(req, debugEventsSchema)
Settings.debugEvents = count || 20
logger.info({ count: Settings.debugEvents }, 'starting debug mode')
res.send(`debug mode will log next ${Settings.debugEvents} events`)
})
@@ -193,6 +203,8 @@ app.use(Metrics.http.monitor(logger))
Router.configure(app, io, sessionSockets)
app.use(handleValidationError)
WebsocketLoadBalancer.listenForEditorEvents(io)
const { port } = Settings.internal.realTime
+65 -14
View File
@@ -2,10 +2,56 @@ import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
import DrainManager from './DrainManager.js'
import ConnectedUsersManager from './ConnectedUsersManager.js'
import logger from '@overleaf/logger'
import { z, zz, parseReq } from '@overleaf/validation-tools'
import { clientIdSchema } from './schemas.js'
const countConnectedClientsSchema = z.object({
params: z.strictObject({
projectId: zz.objectId(),
}),
})
// A generic relay: real-time never reads a named field from this payload,
// it forwards it verbatim to connected socket.io clients in the project's
// room (see WebsocketLoadBalancer.emitToRoom) as the named `message` event.
// The payload is a genuinely open, structured map (or an array of them, one
// message per element) -- there is no fixed shape to model here.
const sendMessageSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
message: z.string().min(1),
}),
body: z
.array(z.record(z.string(), z.unknown()))
.or(z.record(z.string(), z.unknown())),
})
const startDrainSchema = z.object({
query: z.strictObject({
rate: z.coerce.number().default(4),
}),
})
// Rollout-temporary fallback (loosened primary schema; no zod validation
// existed for this route on main); delete when this route's
// REQ_VALIDATION_MODE instrumentation is removed.
const startDrainFallbackSchema = z.object({
query: z.object({
rate: z.coerce.number().default(4),
}),
})
const disconnectClientSchema = z.object({
params: z.strictObject({
client_id: clientIdSchema,
}),
})
export default {
countConnectedClients(req, res) {
const { projectId } = req.params
const {
params: { projectId },
} = parseReq(req, countConnectedClientsSchema, { logOnly: true })
ConnectedUsersManager.countConnectedClients(
projectId,
(err, nConnectedClients) => {
@@ -19,29 +65,32 @@ export default {
},
sendMessage(req, res) {
logger.debug({ message: req.params.message }, 'sending message')
if (Array.isArray(req.body)) {
for (const payload of req.body) {
const { params, body } = parseReq(req, sendMessageSchema, {
logOnly: true,
})
logger.debug({ message: params.message }, 'sending message')
if (Array.isArray(body)) {
for (const payload of body) {
WebsocketLoadBalancer.emitToRoom(
req.params.project_id,
req.params.message,
params.project_id,
params.message,
payload
)
}
} else {
WebsocketLoadBalancer.emitToRoom(
req.params.project_id,
req.params.message,
req.body
)
WebsocketLoadBalancer.emitToRoom(params.project_id, params.message, body)
}
res.sendStatus(204)
},
startDrain(req, res) {
const io = req.app.get('io')
let rate = req.query.rate || '4'
rate = parseFloat(rate) || 0
const {
query: { rate },
} = parseReq(req, startDrainSchema, {
logOnly: true,
fallbackSchema: startDrainFallbackSchema,
})
logger.info({ rate }, 'setting client drain rate')
DrainManager.startDrain(io, rate)
res.sendStatus(204)
@@ -49,7 +98,9 @@ export default {
disconnectClient(req, res, next) {
const io = req.app.get('io')
const { client_id: clientId } = req.params
const {
params: { client_id: clientId },
} = parseReq(req, disconnectClientSchema, { logOnly: true })
const client = io.sockets.sockets[clientId]
if (!client) {
+12 -1
View File
@@ -1,5 +1,14 @@
import { z, parseReq } from '@overleaf/validation-tools'
import { clientIdSchema } from './schemas.js'
let HttpController
const getConnectedClientSchema = z.object({
params: z.strictObject({
client_id: clientIdSchema,
}),
})
export default HttpController = {
// The code in this controller is hard to unit test because of a lot of
// dependencies on internal socket.io methods. It is not critical to the running
@@ -42,7 +51,9 @@ export default HttpController = {
},
getConnectedClient(req, res) {
const { client_id: clientId } = req.params
const {
params: { client_id: clientId },
} = parseReq(req, getConnectedClientSchema, { logOnly: true })
const io = req.app.get('io')
const ioClient = io.sockets.sockets[clientId]
if (!ioClient) {
+77 -4
View File
@@ -19,18 +19,24 @@ const HOSTNAME = os.hostname()
const SERVER_PING_INTERVAL = 15000
const SERVER_PING_LATENCY_THRESHOLD = 5000
const joinDocSchema = z.object({
const joinDocSchema = z.strictObject({
doc_id: zz.objectId(),
fromVersion: z.number().int().optional(),
options: z.object(),
// known fields read by WebsocketController.joinDoc; see
// document-container.ts's joinDoc() call sites on the frontend
options: z.strictObject({
age: z.number().optional(),
supportsHistoryOT: z.boolean().optional(),
encodeRanges: z.boolean().optional(),
}),
})
const applyOtUpdateSchema = z.object({
const applyOtUpdateSchema = z.strictObject({
doc_id: zz.objectId(),
update: z.strictObject({
doc: zz.objectId().optional(),
dupIfSource: z.array(z.string()).optional(),
hash: z.string().optional(),
hash: schemas.rawBlobHash.optional(),
lastV: z.number().optional(),
meta: z
.strictObject({
@@ -56,6 +62,9 @@ const applyOtUpdateSchema = z.object({
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(),
u: z.boolean().optional(),
})
@@ -67,6 +76,37 @@ const applyOtUpdateSchema = z.object({
}),
})
// Fields the client actually sends; see online-users-context.tsx's
// clientTracking.updatePosition emit call. The server then mutates this
// object in place to add its own fields (id/user_id/email/name) before
// broadcasting it on, so those must NOT be accepted from the client.
const cursorDataSchema = z.strictObject({
row: z.number().optional(),
column: z.number().optional(),
doc_id: zz.objectId().nullish(),
})
// A diagnostic echo channel (see use-socket-manager.ts): the payload is
// never read by name, only reflected back to the caller, so this is a
// genuinely open map rather than a fixed shape to approximate. `data` is a
// top-level positional socket.io argument, so an omitted value is
// JSON-encoded as `null`, not `undefined` -- hence .nullish().
const debugDataSchema = z.record(z.string(), z.unknown()).nullish()
// Positional args the client echoes back from a 'serverPing' (see the
// serverPing emit below and connection-manager.ts's sendPingResponse, whose
// params are all optionally-typed); used only for logging/latency metrics.
// A socket.io array of positional args is JSON-encoded, so an omitted
// trailing value arrives as `null`, not `undefined` -- hence .nullish().
const clientPongArgsSchema = z.tuple([
z.number().nullish(),
z.number().nullish(),
z.string().nullish(),
z.string().nullish(),
z.string().nullish(),
z.string().nullish(),
])
let Router
export default Router = {
@@ -334,6 +374,21 @@ export default Router = {
clientTransport,
clientSessionId
) {
try {
clientPongArgsSchema.parse([
receivedPingId,
sentTimestamp,
serverTransport,
serverSessionId,
clientTransport,
clientSessionId,
])
} catch (error) {
// fire-and-forget message, no callback to report back to
return Router._handleError(() => {}, error, client, 'clientPong', {
validation: 1,
})
}
pongId = receivedPingId
const receivedTimestamp = Date.now()
if (
@@ -392,6 +447,13 @@ export default Router = {
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(client, 'debug', arguments)
}
try {
data = debugDataSchema.parse(data)
} catch (error) {
return Router._handleError(callback, error, client, 'debug', {
validation: 1,
})
}
logger.info(
{ publicId: client.publicId, clientId: client.id },
@@ -575,6 +637,17 @@ export default Router = {
arguments
)
}
try {
cursorDataSchema.parse(cursorData)
} catch (error) {
return Router._handleError(
callback,
error,
client,
'clientTracking.updatePosition',
{ validation: 1, disconnect: 1 }
)
}
WebsocketController.updateClientPosition(
client,
+15
View File
@@ -0,0 +1,15 @@
import { z } from '@overleaf/validation-tools'
// A socket.io client id, as generated by this service's pinned socket.io
// fork's Manager.prototype.generateId (node_modules/socket.io/lib/
// manager.js): 15 random bytes, base64-encoded, with '/'->'_' and '+'->'-'
// substituted -- i.e. RFC 4648 base64url, always exactly 20 characters (15
// is a multiple of 3, so standard base64 of it never needs '=' padding).
// Used by HttpController.getConnectedClient and
// HttpApiController.disconnectClient for a dynamic-property lookup into
// socket.io's internal client map (`io.sockets.sockets[clientId]`), so both
// constraints matter: the base64url charset alone still contains
// '__proto__', 'constructor', and 'prototype' as valid strings, but none of
// those is 20 characters long, so the length check is what actually
// excludes them.
export const clientIdSchema = z.base64url().length(20)
@@ -1011,4 +1011,87 @@ describe('applyOtUpdate', function () {
return null
})
})
describe('when authorized with an invalid hash', function () {
before(function (done) {
this.update = {
op: [{ i: 'foo', p: 42 }],
v: 42,
hash: 'not-a-valid-hash',
}
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
})
})
})
@@ -194,7 +194,7 @@ describe('clientTracking', function () {
})
})
return describe('when an anonymous client updates its cursor location', function () {
describe('when an anonymous client updates its cursor location', function () {
before(function (done) {
return async.series(
[
@@ -288,4 +288,161 @@ describe('clientTracking', function () {
])
})
})
describe('when a client updates its cursor location with no document open', function () {
describe('with doc_id set to null', function () {
before(function (done) {
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'owner',
project: { name: 'Test Project' },
},
(error, { user_id: userId, project_id: projectId }) => {
if (error) return done(error)
this.user_id = userId
this.project_id = projectId
return cb()
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit(
'clientTracking.updatePosition',
{
row: 42,
column: 36,
doc_id: null,
},
error => {
this.error = error
// Give a possible disconnect a chance to happen.
return setTimeout(cb, 300)
}
)
},
],
done
)
})
it('should not return an error', function () {
expect(this.error).to.not.exist
})
it('should keep the client connected', function () {
expect(this.client.socket.connected).to.equal(true)
})
})
describe('with doc_id omitted', function () {
before(function (done) {
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'owner',
project: { name: 'Test Project' },
},
(error, { user_id: userId, project_id: projectId }) => {
if (error) return done(error)
this.user_id = userId
this.project_id = projectId
return cb()
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit(
'clientTracking.updatePosition',
{
row: 42,
column: 36,
},
error => {
this.error = error
// Give a possible disconnect a chance to happen.
return setTimeout(cb, 300)
}
)
},
],
done
)
})
it('should not return an error', function () {
expect(this.error).to.not.exist
})
it('should keep the client connected', function () {
expect(this.client.socket.connected).to.equal(true)
})
})
describe('with an invalid doc_id', function () {
before(function (done) {
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'owner',
project: { name: 'Test Project' },
},
(error, { user_id: userId, project_id: projectId }) => {
if (error) return done(error)
this.user_id = userId
this.project_id = projectId
return cb()
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit(
'clientTracking.updatePosition',
{
row: 42,
column: 36,
doc_id: 'not-a-valid-id',
},
error => {
this.error = error
// Give the disconnect a chance to happen.
return setTimeout(cb, 300)
}
)
},
],
done
)
})
it('should return an invalid id error', function () {
return this.error.message.should.equal('invalid Mongo ObjectId')
})
it('should disconnect the client', function () {
return expect(this.client.socket.connected).to.equal(false)
})
})
})
})
@@ -0,0 +1,144 @@
import { expect } from 'chai'
import async from 'async'
import {
fetchNothing,
fetchString,
RequestFailedError,
} from '@overleaf/fetch-utils'
import RealTimeClient from './helpers/RealTimeClient.js'
import FixturesManager from './helpers/FixturesManager.js'
describe('sendMessage', function () {
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{ privilegeLevel: 'owner' },
(error, { project_id: projectId }) => {
this.project_id = projectId
cb(error)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
],
done
)
})
describe('with a well-formed object payload', function () {
before(function (done) {
this.client.once('custom:test:event', payload => {
this.received = payload
done()
})
fetchNothing(
`http://127.0.0.1:3026/project/${this.project_id}/message/custom:test:event`,
{
method: 'POST',
json: { foo: 'bar' },
}
).catch(done)
})
it('should relay the message to connected clients in the room', function () {
expect(this.received).to.deep.equal({ foo: 'bar' })
})
})
describe('with a malformed project id', function () {
it('should return 404', async function () {
try {
await fetchNothing(
'http://127.0.0.1:3026/project/not-a-valid-id/message/custom:test:event',
{
method: 'POST',
json: { foo: 'bar' },
}
)
expect.fail('should have thrown')
} catch (error) {
expect(error).to.be.instanceOf(RequestFailedError)
expect(error.response.status).to.equal(404)
}
})
})
})
describe('disconnectClient', function () {
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{ privilegeLevel: 'owner' },
(error, { project_id: projectId }) => {
this.project_id = projectId
cb(error)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
],
done
)
})
describe('with a malformed client id', function () {
it('should return a schema-validation 404, leaving the real client connected', async function () {
try {
await fetchNothing(
'http://127.0.0.1:3026/client/__proto__/disconnect',
{
method: 'POST',
}
)
expect.fail('should have thrown')
} catch (error) {
expect(error).to.be.instanceOf(RequestFailedError)
expect(error.response.status).to.equal(404)
// The domain-level 404 for an already-disconnected client has no
// JSON body -- a JSON body with statusCode/error here proves this
// request was rejected by the client_id schema (InvalidParamsError
// via handleValidationError), not by the "already disconnected"
// check in HttpApiController.disconnectClient.
const body = JSON.parse(error.body)
expect(body).to.have.property('statusCode', 404)
expect(body.error).to.be.a('string')
}
expect(this.client.socket.connected).to.equal(true)
})
})
describe('with a real client id', function () {
it('should disconnect the client', function (done) {
this.client.on('disconnect', () => done())
fetchNothing(
`http://127.0.0.1:3026/client/${this.client.socket.sessionid}/disconnect`,
{ method: 'POST' }
).catch(done)
})
})
})
describe('GET /debug/events', function () {
it('should accept a numeric count', async function () {
const body = await fetchString('http://127.0.0.1:3026/debug/events?count=5')
expect(body).to.equal('debug mode will log next 5 events')
})
it('should reject a non-numeric count', async function () {
try {
await fetchNothing('http://127.0.0.1:3026/debug/events?count=abc')
expect.fail('should have thrown')
} catch (error) {
expect(error).to.be.instanceOf(RequestFailedError)
expect(error.response.status).to.equal(400)
}
})
})
@@ -27,6 +27,25 @@ describe('HttpControllerTests', function () {
expect(error.response.status).to.equal(404)
}
})
it('should return a schema-validation 404 for a __proto__-shaped client id', async function () {
const clientId = '__proto__'
try {
await fetchNothing(`http://127.0.0.1:3026/clients/${clientId}`)
expect.fail('request should have failed')
} catch (error) {
expect(error).to.be.instanceof(RequestFailedError)
expect(error.response.status).to.equal(404)
// The domain-level 404 (`res.sendStatus(404)` for an id that parses
// but has no matching client) has no JSON body. Asserting a JSON
// body with a statusCode/error here proves this request was
// rejected by the client_id schema (InvalidParamsError via
// handleValidationError), not by the "client not found" check.
const body = JSON.parse(error.body)
expect(body).to.have.property('statusCode', 404)
expect(body.error).to.be.a('string')
}
})
})
describe('with a user and after joining a project', function () {
@@ -83,8 +83,8 @@ describe('leaveDoc', function () {
})
})
return it('should return an error', function () {
return expect(this.error).to.exist
return it('should return an invalid id error', function () {
return this.error.message.should.equal('invalid Mongo ObjectId')
})
})
})
@@ -403,6 +403,11 @@ describe('MatrixTests', function () {
})
describe('send updates', function () {
// Fake blob hashes, distinguishable from each other, standing in for
// whichever client sent the update.
const USER_HASH = '1111111111111111111111111111111111111111'
const PRIVATE_HASH = '2222222222222222222222222222222222222222'
let receivedArgs, submittedUpdates, update
beforeEach(function cleanup(done) {
@@ -427,7 +432,7 @@ describe('MatrixTests', function () {
return this.skip()
}
const userUpdate = Object.assign({}, update.op, {
hash: 'user',
hash: USER_HASH,
})
client.emit(
@@ -443,7 +448,7 @@ describe('MatrixTests', function () {
beforeEach(function sendAsPrivateUserForReferenceOp(done) {
const privateUpdate = Object.assign({}, update.op, {
hash: 'private',
hash: PRIVATE_HASH,
})
privateClient.emit(
@@ -477,7 +482,7 @@ describe('MatrixTests', function () {
it('should submit the private users message only', function () {
expect(submittedUpdates).to.have.length(1)
const update = JSON.parse(submittedUpdates[0])
expect(update.hash).to.equal('private')
expect(update.hash).to.equal(PRIVATE_HASH)
})
})
})
@@ -0,0 +1,137 @@
import { expect } from 'chai'
import async from 'async'
import RealTimeClient from './helpers/RealTimeClient.js'
import FixturesManager from './helpers/FixturesManager.js'
describe('debug message', function () {
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{ privilegeLevel: 'owner' },
(error, { project_id: projectId }) => {
this.project_id = projectId
cb(error)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
],
done
)
})
describe('with a well-formed data payload', function () {
before(function (done) {
this.client.emit('debug', { time: 123 }, (response, ...rest) => {
this.response = response
this.returnedArgs = rest
done()
})
})
it('should echo the data back without an error', function () {
expect(this.returnedArgs).to.deep.equal([])
expect(this.response.data).to.deep.equal({ time: 123 })
})
it('should include the client view', function () {
expect(this.response.client.publicId).to.equal(this.client.publicId)
})
})
describe('with no data payload', function () {
before(function (done) {
this.client.emit('debug', undefined, (response, ...rest) => {
this.response = response
this.returnedArgs = rest
done()
})
})
it('should echo back with no error', function () {
expect(this.returnedArgs).to.deep.equal([])
expect(this.response.data).to.not.exist
})
})
describe('with a non-object data payload', function () {
before(function (done) {
this.client.emit('debug', 'not-an-object', error => {
this.error = error
done()
})
})
it('should return a validation error', function () {
expect(this.error).to.exist
})
})
})
describe('clientPong message', function () {
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{ privilegeLevel: 'owner' },
(error, { project_id: projectId }) => {
this.project_id = projectId
cb(error)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
],
done
)
})
describe('with well-formed pong args', function () {
before(function (done) {
this.client.emit(
'clientPong',
1,
Date.now(),
'websocket',
this.client.socket.sessionid,
'websocket',
this.client.socket.sessionid
)
// clientPong has no ack; give the server a moment to process it, then
// confirm the connection is still healthy via a normal RPC.
setTimeout(() => {
this.client.emit('clientTracking.getConnectedUsers', error => {
this.error = error
done()
})
}, 50)
})
it('should not disrupt the connection', function () {
expect(this.error).to.not.exist
})
})
describe('with malformed pong args', function () {
before(function (done) {
this.client.emit('clientPong', 'not-a-number', 'not-a-number-either')
setTimeout(() => {
this.client.emit('clientTracking.getConnectedUsers', error => {
this.error = error
done()
})
}, 50)
})
it('should be rejected but not disrupt the connection', function () {
expect(this.error).to.not.exist
})
})
})
@@ -11,9 +11,31 @@
*/
import sinon from 'sinon'
import express from 'express'
import { z, zz, parseReq } from '@overleaf/validation-tools'
let MockDocUpdaterServer
// Mirrors the request shape of document-updater's real routes (see
// services/document-updater/app/js/HttpController.js).
const getDocumentRequestSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
doc_id: zz.objectId(),
}),
query: z.strictObject({
fromVersion: z.coerce.number().int().optional(),
// sent by real-time's DocumentUpdaterManager.getDocument alongside
// fromVersion; unused here since this mock only reads fromVersion.
historyOTSupport: z.stringbool().optional(),
}),
})
const deleteProjectRequestSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
}),
})
export default MockDocUpdaterServer = {
docs: {},
@@ -31,9 +53,9 @@ export default MockDocUpdaterServer = {
deleteProject: sinon.stub().callsArg(1),
getDocumentRequest(req, res, next) {
const { project_id: projectId, doc_id: docId } = req.params
let { fromVersion } = req.query
fromVersion = parseInt(fromVersion, 10)
const { params, query } = parseReq(req, getDocumentRequestSchema)
const { project_id: projectId, doc_id: docId } = params
const { fromVersion } = query
return MockDocUpdaterServer.getDocument(
projectId,
docId,
@@ -51,7 +73,8 @@ export default MockDocUpdaterServer = {
},
deleteProjectRequest(req, res, next) {
const { project_id: projectId } = req.params
const { params } = parseReq(req, deleteProjectRequestSchema)
const { project_id: projectId } = params
return MockDocUpdaterServer.deleteProject(projectId, error => {
if (error != null) {
return next(error)
@@ -11,9 +11,22 @@
*/
import sinon from 'sinon'
import express from 'express'
import { z, zz, parseReq } from '@overleaf/validation-tools'
let MockWebServer
// Mirrors the request shape of web's real POST /project/:project_id/join route (see WebApiManager.joinProject).
const joinProjectRequestSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
}),
body: z.strictObject({
// 'anonymous-user' is a valid sentinel, not always a real user id
userId: zz.objectId().or(z.literal('anonymous-user')),
anonymousAccessToken: z.string().optional(),
}),
})
export default MockWebServer = {
projects: {},
privileges: {},
@@ -43,8 +56,9 @@ export default MockWebServer = {
},
joinProjectRequest(req, res, next) {
const { project_id: projectId } = req.params
const { anonymousAccessToken, userId } = req.body
const { params, body } = parseReq(req, joinProjectRequestSchema)
const { project_id: projectId } = params
const { anonymousAccessToken, userId } = body
if (projectId === '404404404404404404404404') {
// not-found
return res.status(404).send()