mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user