Merge pull request #30215 from overleaf/ar/convert-real-time-to-esm
[real-time] convert real time to esm GitOrigin-RevId: 7cc530cc977549d3274be42585735e1fd72cad5f
This commit is contained in:
Generated
+976
-64
File diff suppressed because it is too large
Load Diff
+28
-27
@@ -1,30 +1,34 @@
|
||||
// Metrics must be initialized before importing anything else
|
||||
require('@overleaf/metrics/initialize')
|
||||
import '@overleaf/metrics/initialize.js'
|
||||
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const async = require('async')
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import Settings from '@overleaf/settings'
|
||||
import async from 'async'
|
||||
import logger from '@overleaf/logger'
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
import ConnectRedis from 'connect-redis'
|
||||
import SessionSockets from './app/js/SessionSockets.js'
|
||||
import CookieParser from 'cookie-parser'
|
||||
import DrainManager from './app/js/DrainManager.js'
|
||||
import HealthCheckManager from './app/js/HealthCheckManager.js'
|
||||
import DeploymentManager from './app/js/DeploymentManager.js'
|
||||
import Path from 'node:path'
|
||||
import socketIO from 'socket.io'
|
||||
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 DocumentUpdaterController from './app/js/DocumentUpdaterController.js'
|
||||
|
||||
const logger = require('@overleaf/logger')
|
||||
logger.initialize('real-time')
|
||||
Metrics.event_loop.monitor(logger)
|
||||
Metrics.open_sockets.monitor()
|
||||
|
||||
const express = require('express')
|
||||
const session = require('express-session')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
|
||||
const sessionRedisClient = redis.createClient(Settings.redis.websessions)
|
||||
|
||||
const RedisStore = require('connect-redis')(session)
|
||||
const SessionSockets = require('./app/js/SessionSockets')
|
||||
const CookieParser = require('cookie-parser')
|
||||
|
||||
const DrainManager = require('./app/js/DrainManager')
|
||||
const HealthCheckManager = require('./app/js/HealthCheckManager')
|
||||
const DeploymentManager = require('./app/js/DeploymentManager')
|
||||
|
||||
const Path = require('node:path')
|
||||
const RedisStore = ConnectRedis(session)
|
||||
|
||||
// NOTE: debug is invoked for every blob that is put on the wire
|
||||
const socketIoLogger = {
|
||||
@@ -45,9 +49,9 @@ DeploymentManager.initialise()
|
||||
// Set up socket.io server
|
||||
const app = express()
|
||||
|
||||
const server = require('node:http').createServer(app)
|
||||
const server = http.createServer(app)
|
||||
server.keepAliveTimeout = Settings.keepAliveTimeoutMs
|
||||
const io = require('socket.io').listen(server, {
|
||||
const io = socketIO.listen(server, {
|
||||
logger: socketIoLogger,
|
||||
})
|
||||
|
||||
@@ -127,7 +131,7 @@ io.configure(function () {
|
||||
// The express sendFile method correctly handles conditional
|
||||
// requests using the last-modified time and etag (which is
|
||||
// a combination of mtime and size)
|
||||
const socketIOClientFolder = require('socket.io-client').dist
|
||||
const socketIOClientFolder = socketIOClient.dist
|
||||
app.get('/socket.io/socket.io.js', function (req, res) {
|
||||
res.sendFile(Path.join(socketIOClientFolder, 'socket.io.min.js'))
|
||||
})
|
||||
@@ -156,9 +160,7 @@ app.get('/debug/events', function (req, res) {
|
||||
res.send(`debug mode will log next ${Settings.debugEvents} events`)
|
||||
})
|
||||
|
||||
const rclient = require('@overleaf/redis-wrapper').createClient(
|
||||
Settings.redis.realtime
|
||||
)
|
||||
const rclient = redis.createClient(Settings.redis.realtime)
|
||||
|
||||
function healthCheck(req, res) {
|
||||
rclient.healthCheck(function (error) {
|
||||
@@ -190,13 +192,10 @@ app.get('/health_check/redis', healthCheck)
|
||||
// log http requests for routes defined from this point onwards
|
||||
app.use(Metrics.http.monitor(logger))
|
||||
|
||||
const Router = require('./app/js/Router')
|
||||
Router.configure(app, io, sessionSockets)
|
||||
|
||||
const WebsocketLoadBalancer = require('./app/js/WebsocketLoadBalancer')
|
||||
WebsocketLoadBalancer.listenForEditorEvents(io)
|
||||
|
||||
const DocumentUpdaterController = require('./app/js/DocumentUpdaterController')
|
||||
DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io)
|
||||
|
||||
const { port } = Settings.internal.realTime
|
||||
@@ -352,3 +351,5 @@ if (Settings.continualPubsubTraffic) {
|
||||
|
||||
runPubSubTraffic()
|
||||
}
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const { NotAuthorizedError } = require('./Errors')
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { NotAuthorizedError } = Errors
|
||||
|
||||
let AuthorizationManager
|
||||
module.exports = AuthorizationManager = {
|
||||
|
||||
export default AuthorizationManager = {
|
||||
assertClientCanViewProject(client, callback) {
|
||||
AuthorizationManager._assertClientHasPrivilegeLevel(
|
||||
client,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const logger = require('@overleaf/logger')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const settings = require('@overleaf/settings')
|
||||
const OError = require('@overleaf/o-error')
|
||||
import logger from '@overleaf/logger'
|
||||
import metrics from '@overleaf/metrics'
|
||||
import settings from '@overleaf/settings'
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
const ClientMap = new Map() // for each redis client, store a Map of subscribed channels (channelname -> subscribe promise)
|
||||
|
||||
@@ -9,7 +9,7 @@ const ClientMap = new Map() // for each redis client, store a Map of subscribed
|
||||
// that we never subscribe to a channel multiple times. The socket.io side is
|
||||
// handled by RoomManager.
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
getClientMapEntry(rclient) {
|
||||
// return the per-client channel map if it exists, otherwise create and
|
||||
// return an empty map for the client.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const async = require('async')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
import async from 'async'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
import OError from '@overleaf/o-error'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
const rclient = redis.createClient(Settings.redis.realtime)
|
||||
const Keys = Settings.redis.realtime.key_schema
|
||||
|
||||
@@ -28,7 +28,7 @@ function recordProjectNotEmptySinceMetric(res, status) {
|
||||
Metrics.histogram('project_not_empty_since', diff, BUCKETS, { status })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
countConnectedClients(projectId, callback) {
|
||||
rclient.scard(Keys.clientsInProject({ project_id: projectId }), callback)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const logger = require('@overleaf/logger')
|
||||
const settings = require('@overleaf/settings')
|
||||
const fs = require('node:fs')
|
||||
import logger from '@overleaf/logger'
|
||||
import settings from '@overleaf/settings'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// Monitor a status file (e.g. /etc/real_time_status) periodically and close the
|
||||
// service if the file contents don't contain the matching deployment colour.
|
||||
@@ -45,7 +45,7 @@ function checkStatusFileSync() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
initialise() {
|
||||
if (statusFile && deploymentColour) {
|
||||
logger.info(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
const logger = require('@overleaf/logger')
|
||||
const settings = require('@overleaf/settings')
|
||||
const RedisClientManager = require('./RedisClientManager')
|
||||
const SafeJsonParse = require('./SafeJsonParse')
|
||||
const EventLogger = require('./EventLogger')
|
||||
const HealthCheckManager = require('./HealthCheckManager')
|
||||
const RoomManager = require('./RoomManager')
|
||||
const ChannelManager = require('./ChannelManager')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
import logger from '@overleaf/logger'
|
||||
import settings from '@overleaf/settings'
|
||||
import RedisClientManager from './RedisClientManager.js'
|
||||
import SafeJsonParse from './SafeJsonParse.js'
|
||||
import EventLogger from './EventLogger.js'
|
||||
import HealthCheckManager from './HealthCheckManager.js'
|
||||
import RoomManager from './RoomManager.js'
|
||||
import ChannelManager from './ChannelManager.js'
|
||||
import metrics from '@overleaf/metrics'
|
||||
|
||||
let DocumentUpdaterController
|
||||
module.exports = DocumentUpdaterController = {
|
||||
|
||||
export default DocumentUpdaterController = {
|
||||
// DocumentUpdaterController is responsible for updates that come via Redis
|
||||
// Pub/Sub from the document updater.
|
||||
rclientList: RedisClientManager.createClientList(settings.redis.pubsub),
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
const request = require('request')
|
||||
const _ = require('lodash')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const logger = require('@overleaf/logger')
|
||||
const settings = require('@overleaf/settings')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
import request from 'request'
|
||||
import _ from 'lodash'
|
||||
import OError from '@overleaf/o-error'
|
||||
import logger from '@overleaf/logger'
|
||||
import settings from '@overleaf/settings'
|
||||
import metrics from '@overleaf/metrics'
|
||||
import RedisWrapper from '@overleaf/redis-wrapper'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const {
|
||||
ClientRequestedMissingOpsError,
|
||||
DocumentUpdaterRequestFailedError,
|
||||
NullBytesInOpError,
|
||||
UpdateTooLargeError,
|
||||
} = require('./Errors')
|
||||
|
||||
const rclient = require('@overleaf/redis-wrapper').createClient(
|
||||
settings.redis.documentupdater
|
||||
)
|
||||
} = Errors
|
||||
const rclient = RedisWrapper.createClient(settings.redis.documentupdater)
|
||||
const Keys = settings.redis.documentupdater.key_schema
|
||||
|
||||
const DocumentUpdaterManager = {
|
||||
@@ -154,4 +154,4 @@ const DocumentUpdaterManager = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = DocumentUpdaterManager
|
||||
export default DocumentUpdaterManager
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const logger = require('@overleaf/logger')
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
startDrainTimeWindow(io, minsToDrain, callback) {
|
||||
const drainPerMin = io.sockets.clients().length / minsToDrain
|
||||
// enforce minimum drain rate
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const OError = require('@overleaf/o-error')
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
class ClientRequestedMissingOpsError extends OError {
|
||||
constructor(statusCode, info = {}) {
|
||||
@@ -87,7 +87,7 @@ class WebApiRequestFailedError extends OError {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
CodedError,
|
||||
CorruptedJoinProjectResponseError,
|
||||
ClientRequestedMissingOpsError,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import metrics from '@overleaf/metrics'
|
||||
import settings from '@overleaf/settings'
|
||||
let EventLogger
|
||||
const logger = require('@overleaf/logger')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const settings = require('@overleaf/settings')
|
||||
|
||||
// keep track of message counters to detect duplicate and out of order events
|
||||
// messsage ids have the format "UNIQUEHOSTKEY-COUNTER"
|
||||
@@ -13,7 +13,7 @@ let EVENT_LAST_CLEAN_TIMESTAMP = 0
|
||||
// counter for debug logs
|
||||
let COUNTER = 0
|
||||
|
||||
module.exports = EventLogger = {
|
||||
export default EventLogger = {
|
||||
MAX_STALE_TIME_IN_MS: 3600 * 1000,
|
||||
|
||||
debugEvent(channel, message) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const logger = require('@overleaf/logger')
|
||||
|
||||
const os = require('node:os')
|
||||
import metrics from '@overleaf/metrics'
|
||||
import logger from '@overleaf/logger'
|
||||
import os from 'node:os'
|
||||
const HOST = os.hostname()
|
||||
const PID = process.pid
|
||||
let COUNT = 0
|
||||
@@ -9,7 +8,7 @@ let COUNT = 0
|
||||
const CHANNEL_MANAGER = {} // hash of event checkers by channel name
|
||||
const CHANNEL_ERROR = {} // error status by channel name
|
||||
|
||||
module.exports = class HealthCheckManager {
|
||||
export default class HealthCheckManager {
|
||||
// create an instance of this class which checks that an event with a unique
|
||||
// id is received only once within a timeout
|
||||
constructor(channel, timeout) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const WebsocketLoadBalancer = require('./WebsocketLoadBalancer')
|
||||
const DrainManager = require('./DrainManager')
|
||||
const ConnectedUsersManager = require('./ConnectedUsersManager')
|
||||
const logger = require('@overleaf/logger')
|
||||
import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
|
||||
import DrainManager from './DrainManager.js'
|
||||
import ConnectedUsersManager from './ConnectedUsersManager.js'
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
countConnectedClients(req, res) {
|
||||
const { projectId } = req.params
|
||||
ConnectedUsersManager.countConnectedClients(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
let HttpController
|
||||
module.exports = HttpController = {
|
||||
|
||||
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
|
||||
// of Overleaf, and is only used for getting stats about connected clients,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const logger = require('@overleaf/logger')
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
createClientList(...configs) {
|
||||
// create a dynamic list of redis clients, excluding any configurations which are not defined
|
||||
return configs.filter(Boolean).map(x => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const logger = require('@overleaf/logger')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const OError = require('@overleaf/o-error')
|
||||
import logger from '@overleaf/logger'
|
||||
import metrics from '@overleaf/metrics'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
const IdMap = new Map() // keep track of whether ids are from projects or docs
|
||||
const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events
|
||||
@@ -16,7 +16,7 @@ const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project
|
||||
//
|
||||
// The pubsub side is handled by ChannelManager
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
joinProject(client, projectId, callback) {
|
||||
this.joinEntity(client, 'project', projectId, callback)
|
||||
},
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const logger = require('@overleaf/logger')
|
||||
const settings = require('@overleaf/settings')
|
||||
const WebsocketController = require('./WebsocketController')
|
||||
const HttpController = require('./HttpController')
|
||||
const HttpApiController = require('./HttpApiController')
|
||||
const WebsocketAddressManager = require('./WebsocketAddressManager')
|
||||
const bodyParser = require('body-parser')
|
||||
const base64id = require('base64id')
|
||||
const { UnexpectedArgumentsError } = require('./Errors')
|
||||
const { z, zz } = require('@overleaf/validation-tools')
|
||||
const { isZodErrorLike } = require('zod-validation-error')
|
||||
import metrics from '@overleaf/metrics'
|
||||
import logger from '@overleaf/logger'
|
||||
import settings from '@overleaf/settings'
|
||||
import WebsocketController from './WebsocketController.js'
|
||||
import HttpController from './HttpController.js'
|
||||
import HttpApiController from './HttpApiController.js'
|
||||
import WebsocketAddressManager from './WebsocketAddressManager.js'
|
||||
import bodyParser from 'body-parser'
|
||||
import base64id from 'base64id'
|
||||
import Errors from './Errors.js'
|
||||
import { z, zz } from '@overleaf/validation-tools'
|
||||
import { isZodErrorLike } from 'zod-validation-error'
|
||||
import os from 'node:os'
|
||||
|
||||
const HOSTNAME = require('node:os').hostname()
|
||||
const { UnexpectedArgumentsError } = Errors
|
||||
|
||||
const HOSTNAME = os.hostname()
|
||||
const SERVER_PING_INTERVAL = 15000
|
||||
const SERVER_PING_LATENCY_THRESHOLD = 5000
|
||||
|
||||
@@ -27,7 +30,8 @@ const applyOtUpdateSchema = z.object({
|
||||
})
|
||||
|
||||
let Router
|
||||
module.exports = Router = {
|
||||
|
||||
export default Router = {
|
||||
_handleError(callback, error, client, method, attrs) {
|
||||
attrs = attrs || {}
|
||||
for (const key of ['project_id', 'user_id']) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const Settings = require('@overleaf/settings')
|
||||
const { DataTooLargeToParseError } = require('./Errors')
|
||||
import Settings from '@overleaf/settings'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
module.exports = {
|
||||
const { DataTooLargeToParseError } = Errors
|
||||
|
||||
export default {
|
||||
parse(data, callback) {
|
||||
if (data.length > Settings.maxUpdateSize) {
|
||||
return callback(new DataTooLargeToParseError(data))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const { MissingSessionError } = require('./Errors')
|
||||
import metrics from '@overleaf/metrics'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
module.exports = function (io, sessionStore, cookieParser, cookieName) {
|
||||
const { MissingSessionError } = Errors
|
||||
|
||||
export default function (io, sessionStore, cookieParser, cookieName) {
|
||||
const missingSessionError = new MissingSessionError()
|
||||
|
||||
const sessionSockets = new EventEmitter()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
const request = require('request')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
import request from 'request'
|
||||
import OError from '@overleaf/o-error'
|
||||
import settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const {
|
||||
CodedError,
|
||||
CorruptedJoinProjectResponseError,
|
||||
NotAuthorizedError,
|
||||
WebApiRequestFailedError,
|
||||
} = require('./Errors')
|
||||
} = Errors
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
joinProject(projectId, user, callback) {
|
||||
const userId = user._id
|
||||
logger.debug({ projectId, userId }, 'sending join project request to web')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const proxyaddr = require('proxy-addr')
|
||||
import proxyaddr from 'proxy-addr'
|
||||
|
||||
module.exports = class WebsocketAddressManager {
|
||||
export default class WebsocketAddressManager {
|
||||
constructor(behindProxy, trustedProxyIps) {
|
||||
if (behindProxy) {
|
||||
// parse trustedProxyIps comma-separated list the same way as express
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
const OError = require('@overleaf/o-error')
|
||||
const logger = require('@overleaf/logger')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const WebApiManager = require('./WebApiManager')
|
||||
const AuthorizationManager = require('./AuthorizationManager')
|
||||
const DocumentUpdaterManager = require('./DocumentUpdaterManager')
|
||||
const ConnectedUsersManager = require('./ConnectedUsersManager')
|
||||
const WebsocketLoadBalancer = require('./WebsocketLoadBalancer')
|
||||
const RoomManager = require('./RoomManager')
|
||||
import OError from '@overleaf/o-error'
|
||||
import logger from '@overleaf/logger'
|
||||
import metrics from '@overleaf/metrics'
|
||||
import WebApiManager from './WebApiManager.js'
|
||||
import AuthorizationManager from './AuthorizationManager.js'
|
||||
import DocumentUpdaterManager from './DocumentUpdaterManager.js'
|
||||
import ConnectedUsersManager from './ConnectedUsersManager.js'
|
||||
import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
|
||||
import RoomManager from './RoomManager.js'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const {
|
||||
CodedError,
|
||||
JoinLeaveEpochMismatchError,
|
||||
NotAuthorizedError,
|
||||
NotJoinedError,
|
||||
ClientRequestedMissingOpsError,
|
||||
} = require('./Errors')
|
||||
} = Errors
|
||||
|
||||
const JOIN_DOC_CATCH_UP_LENGTH_BUCKETS = [
|
||||
0, 5, 10, 25, 50, 100, 150, 200, 250, 500, 1000,
|
||||
@@ -35,7 +37,8 @@ const JOIN_DOC_CATCH_UP_AGE = [
|
||||
].map(x => x * 1000)
|
||||
|
||||
let WebsocketController
|
||||
module.exports = WebsocketController = {
|
||||
|
||||
export default WebsocketController = {
|
||||
// If the protocol version changes when the client reconnects,
|
||||
// it will force a full refresh of the page. Useful for non-backwards
|
||||
// compatible protocol changes. Use only in extreme need.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const Settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
const RedisClientManager = require('./RedisClientManager')
|
||||
const SafeJsonParse = require('./SafeJsonParse')
|
||||
const EventLogger = require('./EventLogger')
|
||||
const HealthCheckManager = require('./HealthCheckManager')
|
||||
const RoomManager = require('./RoomManager')
|
||||
const ChannelManager = require('./ChannelManager')
|
||||
const ConnectedUsersManager = require('./ConnectedUsersManager')
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import RedisClientManager from './RedisClientManager.js'
|
||||
import SafeJsonParse from './SafeJsonParse.js'
|
||||
import EventLogger from './EventLogger.js'
|
||||
import HealthCheckManager from './HealthCheckManager.js'
|
||||
import RoomManager from './RoomManager.js'
|
||||
import ChannelManager from './ChannelManager.js'
|
||||
import ConnectedUsersManager from './ConnectedUsersManager.js'
|
||||
|
||||
const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [
|
||||
'otUpdateApplied',
|
||||
@@ -32,7 +32,8 @@ for (let i = 5; i <= 22; i++) {
|
||||
}
|
||||
|
||||
let WebsocketLoadBalancer
|
||||
module.exports = WebsocketLoadBalancer = {
|
||||
|
||||
export default WebsocketLoadBalancer = {
|
||||
rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub),
|
||||
rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub),
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ real-time
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
--test-unit-vitest=True
|
||||
--tsconfig-extra-includes=vitest.config.unit.cjs
|
||||
|
||||
@@ -8,12 +8,14 @@ services:
|
||||
user: node
|
||||
volumes:
|
||||
- ./reports:/overleaf/services/real-time/reports
|
||||
- ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
|
||||
command: npm run test:unit:_run
|
||||
environment:
|
||||
CI:
|
||||
MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf
|
||||
NODE_ENV: test
|
||||
NODE_OPTIONS: "--unhandled-rejections=strict"
|
||||
VITEST_NO_CACHE: true
|
||||
REDIS_HOST: redis_test
|
||||
QUEUES_REDIS_HOST: redis_test
|
||||
HISTORY_REDIS_HOST: redis_test
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- .:/overleaf/services/real-time
|
||||
- ../../node_modules:/overleaf/node_modules
|
||||
- ../../libraries:/overleaf/libraries
|
||||
- ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
|
||||
working_dir: /overleaf/services/real-time
|
||||
environment:
|
||||
MOCHA_GREP: ${MOCHA_GREP}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
"description": "The socket.io layer of Overleaf for real-time editor interactions",
|
||||
"private": true,
|
||||
"main": "app.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"test:acceptance:_run": "mocha --recursive --timeout 15000 --exit $@ test/acceptance/js",
|
||||
"test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP",
|
||||
"test:unit:_run": "mocha --recursive --exit $@ test/unit/js",
|
||||
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
|
||||
"test:unit:_run": "vitest --config ./vitest.config.unit.cjs",
|
||||
"test:unit": "npm run test:unit:_run",
|
||||
"nodemon": "node --watch app.js",
|
||||
"lint": "eslint --max-warnings 0 --format unix .",
|
||||
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts}'",
|
||||
@@ -46,11 +47,11 @@
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"sandboxed-module": "~0.3.0",
|
||||
"sinon": "^9.2.4",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"sinon-chai": "4.0.1",
|
||||
"timekeeper": "0.0.4",
|
||||
"typescript": "^5.0.4",
|
||||
"uid-safe": "^2.1.5"
|
||||
"uid-safe": "^2.1.5",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@
|
||||
* DS201: Simplify complex destructure assignments
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const async = require('async')
|
||||
const { expect } = require('chai')
|
||||
import async from 'async'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
import { expect } from 'chai'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
const rclient = redis.createClient(settings.redis.documentupdater)
|
||||
|
||||
const redisSettings = settings.redis
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import MockWebServer from './helpers/MockWebServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
|
||||
describe('clientTracking', function () {
|
||||
describe('when another logged in user joins a project', function () {
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
|
||||
const { expect } = require('chai')
|
||||
|
||||
const async = require('async')
|
||||
const request = require('request')
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import { expect } from 'chai'
|
||||
import async from 'async'
|
||||
import request from 'request'
|
||||
|
||||
const drain = function (rate, callback) {
|
||||
request.post(
|
||||
|
||||
@@ -9,16 +9,15 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const async = require('async')
|
||||
const { expect } = require('chai')
|
||||
import async from 'async'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
import { expect } from 'chai'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
|
||||
import MockWebServer from './helpers/MockWebServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
const rclientRT = redis.createClient(settings.redis.realtime)
|
||||
const KeysRT = settings.redis.realtime.key_schema
|
||||
|
||||
@@ -5,20 +5,21 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const async = require('async')
|
||||
const { expect } = require('chai')
|
||||
const request = require('request').defaults({
|
||||
import async from 'async'
|
||||
import Request from 'request'
|
||||
import { expect } from 'chai'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
|
||||
const request = Request.defaults({
|
||||
baseUrl: 'http://127.0.0.1:3026',
|
||||
})
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
describe('HttpControllerTests', function () {
|
||||
describe('without a user', function () {
|
||||
return it('should return 404 for the client view', function (done) {
|
||||
it('should return 404 for the client view', function (done) {
|
||||
const clientId = 'not-existing'
|
||||
return request.get(
|
||||
request.get(
|
||||
{
|
||||
url: `/clients/${clientId}`,
|
||||
json: true,
|
||||
@@ -28,36 +29,36 @@ describe('HttpControllerTests', function () {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(404)
|
||||
return done()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with a user and after joining a project', function () {
|
||||
describe('with a user and after joining a project', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
},
|
||||
(error, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(error)
|
||||
cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{},
|
||||
(error, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(error)
|
||||
cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -67,15 +68,15 @@ describe('HttpControllerTests', function () {
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should send a client view', function (done) {
|
||||
return request.get(
|
||||
it('should send a client view', function (done) {
|
||||
request.get(
|
||||
{
|
||||
url: `/clients/${this.client.socket.sessionid}`,
|
||||
json: true,
|
||||
@@ -97,7 +98,7 @@ describe('HttpControllerTests', function () {
|
||||
user_id: this.user_id,
|
||||
rooms: [this.project_id, this.doc_id],
|
||||
})
|
||||
return done()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
|
||||
describe('joinDoc', function () {
|
||||
before(function () {
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import MockWebServer from './helpers/MockWebServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
|
||||
describe('joinProject', function () {
|
||||
describe('when authorized', function () {
|
||||
|
||||
@@ -11,15 +11,14 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const logger = require('@overleaf/logger')
|
||||
|
||||
const async = require('async')
|
||||
import sinon from 'sinon'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import logger from '@overleaf/logger'
|
||||
import async from 'async'
|
||||
|
||||
describe('leaveDoc', function () {
|
||||
before(function () {
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('leaveProject', function () {
|
||||
|
||||
@@ -45,16 +45,16 @@ There is additional meta-data that UserItems and SessionItems may use to skip
|
||||
SessionItem: { needsOwnProject: true }
|
||||
*/
|
||||
|
||||
const { expect } = require('chai')
|
||||
const async = require('async')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
import async from 'async'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import MockWebServer from './helpers/MockWebServer.js'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const Keys = settings.redis.documentupdater.key_schema
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
function getPendingUpdates(docId, cb) {
|
||||
|
||||
@@ -8,14 +8,13 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('PubSubRace', function () {
|
||||
|
||||
@@ -9,15 +9,13 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('receiveEditorEvent', function () {
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import MockWebServer from './helpers/MockWebServer.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import async from 'async'
|
||||
import settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('receiveUpdate', function () {
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const async = require('async')
|
||||
const { expect } = require('chai')
|
||||
import async from 'async'
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
import { expect } from 'chai'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
|
||||
describe('Router', function () {
|
||||
return describe('joinProject', function () {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const signature = require('cookie-signature')
|
||||
const { expect } = require('chai')
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import Settings from '@overleaf/settings'
|
||||
import signature from 'cookie-signature'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('SessionSockets', function () {
|
||||
beforeEach(function (done) {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
import { expect } from 'chai'
|
||||
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
import FixturesManager from './helpers/FixturesManager.js'
|
||||
import RealTimeClient from './helpers/RealTimeClient.js'
|
||||
|
||||
describe('Session', function () {
|
||||
return describe('with an established session', function () {
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let FixturesManager
|
||||
const RealTimeClient = require('./RealTimeClient')
|
||||
const MockWebServer = require('./MockWebServer')
|
||||
const MockDocUpdaterServer = require('./MockDocUpdaterServer')
|
||||
import RealTimeClient from './RealTimeClient.js'
|
||||
import MockWebServer from './MockWebServer.js'
|
||||
import MockDocUpdaterServer from './MockDocUpdaterServer.js'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
module.exports = FixturesManager = {
|
||||
let FixturesManager
|
||||
|
||||
export default FixturesManager = {
|
||||
setUpProject(options, callback) {
|
||||
if (options == null) {
|
||||
options = {}
|
||||
@@ -151,7 +153,7 @@ module.exports = FixturesManager = {
|
||||
},
|
||||
|
||||
getRandomId() {
|
||||
return require('node:crypto')
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(Math.random().toString())
|
||||
.digest('hex')
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let MockDocUpdaterServer
|
||||
const sinon = require('sinon')
|
||||
const express = require('express')
|
||||
import sinon from 'sinon'
|
||||
import express from 'express'
|
||||
|
||||
module.exports = MockDocUpdaterServer = {
|
||||
let MockDocUpdaterServer
|
||||
|
||||
export default MockDocUpdaterServer = {
|
||||
docs: {},
|
||||
|
||||
createMockDoc(projectId, docId, data) {
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let MockWebServer
|
||||
const sinon = require('sinon')
|
||||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
import sinon from 'sinon'
|
||||
import express from 'express'
|
||||
import bodyParser from 'body-parser'
|
||||
|
||||
module.exports = MockWebServer = {
|
||||
let MockWebServer
|
||||
|
||||
export default MockWebServer = {
|
||||
projects: {},
|
||||
privileges: {},
|
||||
userMetadata: {},
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const { XMLHttpRequest } = require('../../libs/XMLHttpRequest')
|
||||
const io = require('socket.io-client')
|
||||
import io from 'socket.io-client'
|
||||
|
||||
import Settings from '@overleaf/settings'
|
||||
import redis from '@overleaf/redis-wrapper'
|
||||
|
||||
import uidSafe from 'uid-safe'
|
||||
import signature from 'cookie-signature'
|
||||
import { callbackify } from 'node:util'
|
||||
import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
|
||||
import { XMLHttpRequest } from '../../libs/XMLHttpRequest.js'
|
||||
|
||||
const Settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(Settings.redis.websessions)
|
||||
|
||||
const uid = require('uid-safe').sync
|
||||
const signature = require('cookie-signature')
|
||||
const { callbackify } = require('node:util')
|
||||
const { fetchJson, fetchNothing } = require('@overleaf/fetch-utils')
|
||||
const uid = uidSafe.sync
|
||||
|
||||
io.util.request = function () {
|
||||
const xhr = new XMLHttpRequest()
|
||||
@@ -150,4 +152,4 @@ const Client = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Client
|
||||
export default Client
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const app = require('../../../../app')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Settings = require('@overleaf/settings')
|
||||
|
||||
module.exports = {
|
||||
running: false,
|
||||
initing: false,
|
||||
callbacks: [],
|
||||
ensureRunning(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (this.running) {
|
||||
return callback()
|
||||
} else if (this.initing) {
|
||||
return this.callbacks.push(callback)
|
||||
} else {
|
||||
this.initing = true
|
||||
this.callbacks.push(callback)
|
||||
return app.listen(
|
||||
__guard__(
|
||||
Settings.internal != null ? Settings.internal.realtime : undefined,
|
||||
x => x.port
|
||||
),
|
||||
'127.0.0.1',
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
this.running = true
|
||||
logger.info('clsi running in dev mode')
|
||||
|
||||
return (() => {
|
||||
const result = []
|
||||
for (callback of Array.from(this.callbacks)) {
|
||||
result.push(callback())
|
||||
}
|
||||
return result
|
||||
})()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return typeof value !== 'undefined' && value !== null
|
||||
? transform(value)
|
||||
: undefined
|
||||
}
|
||||
@@ -11,17 +11,17 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const { URL } = require('node:url')
|
||||
const spawn = require('node:child_process').spawn
|
||||
const fs = require('node:fs')
|
||||
import { URL } from 'node:url'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
|
||||
exports.XMLHttpRequest = function () {
|
||||
export const XMLHttpRequest = function () {
|
||||
/**
|
||||
* Private variables
|
||||
*/
|
||||
const self = this
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
// Holds http.js objects
|
||||
let request
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const chai = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const chaiAsPromised = require('chai-as-promised')
|
||||
const sinonChai = require('sinon-chai')
|
||||
import * as chai from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import chaiAsPromised from 'chai-as-promised'
|
||||
import sinonChai from 'sinon-chai'
|
||||
import '../app.js'
|
||||
|
||||
// Chai configuration
|
||||
chai.should()
|
||||
@@ -22,21 +22,8 @@ const stubs = {
|
||||
},
|
||||
}
|
||||
|
||||
// SandboxedModule configuration
|
||||
SandboxedModule.configure({
|
||||
requires: {
|
||||
'@overleaf/logger': stubs.logger,
|
||||
},
|
||||
globals: { Buffer, JSON, console, process },
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mocha hooks
|
||||
exports.mochaHooks = {
|
||||
export const mochaHooks = {
|
||||
beforeEach() {
|
||||
this.logger = stubs.logger
|
||||
},
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { beforeEach, describe, chai, expect, it } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
|
||||
chai.should()
|
||||
|
||||
const modulePath = '../../../app/js/AuthorizationManager'
|
||||
|
||||
describe('AuthorizationManager', () => {
|
||||
beforeEach(async ctx => {
|
||||
ctx.client = { ol_context: {} }
|
||||
|
||||
ctx.AuthorizationManager = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('assertClientCanViewProject', () => {
|
||||
it('should allow the readOnly privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'readOnly'
|
||||
ctx.AuthorizationManager.assertClientCanViewProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow the readAndWrite privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'readAndWrite'
|
||||
ctx.AuthorizationManager.assertClientCanViewProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow the review privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'review'
|
||||
ctx.AuthorizationManager.assertClientCanViewProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow the owner privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'owner'
|
||||
ctx.AuthorizationManager.assertClientCanViewProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error with any other privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'unknown'
|
||||
ctx.AuthorizationManager.assertClientCanViewProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
error.message.should.equal('not authorized')
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertClientCanEditProject', () => {
|
||||
it('should not allow the readOnly privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'readOnly'
|
||||
ctx.AuthorizationManager.assertClientCanEditProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
error.message.should.equal('not authorized')
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow the readAndWrite privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'readAndWrite'
|
||||
ctx.AuthorizationManager.assertClientCanEditProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow the owner privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'owner'
|
||||
ctx.AuthorizationManager.assertClientCanEditProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error with any other privilegeLevel', async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.client.ol_context.privilege_level = 'unknown'
|
||||
ctx.AuthorizationManager.assertClientCanEditProject(
|
||||
ctx.client,
|
||||
error => {
|
||||
error.message.should.equal('not authorized')
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// check doc access for project
|
||||
|
||||
describe('assertClientCanViewProjectAndDoc', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.doc_id = '12345'
|
||||
ctx.callback = sinon.stub()
|
||||
ctx.client.ol_context = {}
|
||||
})
|
||||
|
||||
describe('when not authorised at the project level', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.client.ol_context.privilege_level = 'unknown'
|
||||
})
|
||||
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
|
||||
describe('even when authorised at the doc level', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorised at the project level', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.client.ol_context.privilege_level = 'readOnly'
|
||||
})
|
||||
|
||||
describe('and not authorised at the document level', () => {
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and authorised at the document level', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
ctx.callback
|
||||
)
|
||||
ctx.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when document authorisation is added and then removed', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
() => {
|
||||
ctx.AuthorizationManager.removeAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should deny access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertClientCanEditProjectAndDoc', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.doc_id = '12345'
|
||||
ctx.callback = sinon.stub()
|
||||
ctx.client.ol_context = {}
|
||||
})
|
||||
|
||||
describe('when not authorised at the project level', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.client.ol_context.privilege_level = 'readOnly'
|
||||
})
|
||||
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
|
||||
describe('even when authorised at the doc level', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorised at the project level', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.client.ol_context.privilege_level = 'readAndWrite'
|
||||
})
|
||||
|
||||
describe('and not authorised at the document level', () => {
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and authorised at the document level', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
ctx.callback
|
||||
)
|
||||
ctx.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when document authorisation is added and then removed', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
() => {
|
||||
ctx.AuthorizationManager.removeAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should deny access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertClientCanReviewProjectAndDoc', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.doc_id = '12345'
|
||||
ctx.callback = sinon.stub()
|
||||
ctx.client.ol_context = {}
|
||||
})
|
||||
|
||||
describe('when not authorised at the project level', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.client.ol_context.privilege_level = 'readOnly'
|
||||
})
|
||||
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
|
||||
describe('even when authorised at the doc level', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorised at the project level', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.client.ol_context.privilege_level = 'review'
|
||||
})
|
||||
|
||||
describe('and not authorised at the document level', () => {
|
||||
it('should not allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and authorised at the document level', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
ctx.callback
|
||||
)
|
||||
ctx.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when document authorisation is added and then removed', () => {
|
||||
beforeEach(async ctx => {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.AuthorizationManager.addAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
() => {
|
||||
ctx.AuthorizationManager.removeAccessToDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should deny access', ctx => {
|
||||
ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,428 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('node:path')
|
||||
const modulePath = '../../../app/js/AuthorizationManager'
|
||||
|
||||
describe('AuthorizationManager', function () {
|
||||
beforeEach(function () {
|
||||
this.client = { ol_context: {} }
|
||||
|
||||
return (this.AuthorizationManager = SandboxedModule.require(modulePath, {
|
||||
requires: {},
|
||||
}))
|
||||
})
|
||||
|
||||
describe('assertClientCanViewProject', function () {
|
||||
it('should allow the readOnly privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'readOnly'
|
||||
return this.AuthorizationManager.assertClientCanViewProject(
|
||||
this.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow the readAndWrite privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'readAndWrite'
|
||||
return this.AuthorizationManager.assertClientCanViewProject(
|
||||
this.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow the review privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'review'
|
||||
return this.AuthorizationManager.assertClientCanViewProject(
|
||||
this.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow the owner privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'owner'
|
||||
return this.AuthorizationManager.assertClientCanViewProject(
|
||||
this.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return an error with any other privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'unknown'
|
||||
return this.AuthorizationManager.assertClientCanViewProject(
|
||||
this.client,
|
||||
error => {
|
||||
error.message.should.equal('not authorized')
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertClientCanEditProject', function () {
|
||||
it('should not allow the readOnly privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'readOnly'
|
||||
return this.AuthorizationManager.assertClientCanEditProject(
|
||||
this.client,
|
||||
error => {
|
||||
error.message.should.equal('not authorized')
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow the readAndWrite privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'readAndWrite'
|
||||
return this.AuthorizationManager.assertClientCanEditProject(
|
||||
this.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow the owner privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'owner'
|
||||
return this.AuthorizationManager.assertClientCanEditProject(
|
||||
this.client,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return an error with any other privilegeLevel', function (done) {
|
||||
this.client.ol_context.privilege_level = 'unknown'
|
||||
return this.AuthorizationManager.assertClientCanEditProject(
|
||||
this.client,
|
||||
error => {
|
||||
error.message.should.equal('not authorized')
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// check doc access for project
|
||||
|
||||
describe('assertClientCanViewProjectAndDoc', function () {
|
||||
beforeEach(function () {
|
||||
this.doc_id = '12345'
|
||||
this.callback = sinon.stub()
|
||||
return (this.client.ol_context = {})
|
||||
})
|
||||
|
||||
describe('when not authorised at the project level', function () {
|
||||
beforeEach(function () {
|
||||
return (this.client.ol_context.privilege_level = 'unknown')
|
||||
})
|
||||
|
||||
it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
|
||||
return describe('even when authorised at the doc level', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when authorised at the project level', function () {
|
||||
beforeEach(function () {
|
||||
return (this.client.ol_context.privilege_level = 'readOnly')
|
||||
})
|
||||
|
||||
describe('and not authorised at the document level', function () {
|
||||
return it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and authorised at the document level', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should allow access', function () {
|
||||
this.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
this.callback
|
||||
)
|
||||
return this.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when document authorisation is added and then removed', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
() => {
|
||||
return this.AuthorizationManager.removeAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should deny access', function () {
|
||||
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertClientCanEditProjectAndDoc', function () {
|
||||
beforeEach(function () {
|
||||
this.doc_id = '12345'
|
||||
this.callback = sinon.stub()
|
||||
return (this.client.ol_context = {})
|
||||
})
|
||||
|
||||
describe('when not authorised at the project level', function () {
|
||||
beforeEach(function () {
|
||||
return (this.client.ol_context.privilege_level = 'readOnly')
|
||||
})
|
||||
|
||||
it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
|
||||
return describe('even when authorised at the doc level', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when authorised at the project level', function () {
|
||||
beforeEach(function () {
|
||||
return (this.client.ol_context.privilege_level = 'readAndWrite')
|
||||
})
|
||||
|
||||
describe('and not authorised at the document level', function () {
|
||||
return it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and authorised at the document level', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should allow access', function () {
|
||||
this.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
this.callback
|
||||
)
|
||||
return this.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when document authorisation is added and then removed', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
() => {
|
||||
return this.AuthorizationManager.removeAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should deny access', function () {
|
||||
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('assertClientCanReviewProjectAndDoc', function () {
|
||||
beforeEach(function () {
|
||||
this.doc_id = '12345'
|
||||
this.callback = sinon.stub()
|
||||
return (this.client.ol_context = {})
|
||||
})
|
||||
|
||||
describe('when not authorised at the project level', function () {
|
||||
beforeEach(function () {
|
||||
return (this.client.ol_context.privilege_level = 'readOnly')
|
||||
})
|
||||
|
||||
it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
|
||||
return describe('even when authorised at the doc level', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when authorised at the project level', function () {
|
||||
beforeEach(function () {
|
||||
return (this.client.ol_context.privilege_level = 'review')
|
||||
})
|
||||
|
||||
describe('and not authorised at the document level', function () {
|
||||
return it('should not allow access', function () {
|
||||
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and authorised at the document level', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should allow access', function () {
|
||||
this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
this.callback
|
||||
)
|
||||
return this.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when document authorisation is added and then removed', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.AuthorizationManager.addAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
() => {
|
||||
return this.AuthorizationManager.removeAccessToDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
done
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should deny access', function () {
|
||||
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
this.client,
|
||||
this.doc_id,
|
||||
err => err.message.should.equal('not authorized')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
import { vi, expect, describe, beforeEach, it } from 'vitest'
|
||||
|
||||
import sinon from 'sinon'
|
||||
|
||||
const modulePath = '../../../app/js/ChannelManager.js'
|
||||
|
||||
describe('ChannelManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.rclient = {}
|
||||
ctx.other_rclient = {}
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {}),
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: (ctx.metrics = {
|
||||
inc: sinon.stub(),
|
||||
summary: sinon.stub(),
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.ChannelManager = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('subscribe', function () {
|
||||
describe('when there is no existing subscription for this redis client', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should subscribe to the redis channel', function (ctx) {
|
||||
ctx.rclient.subscribe
|
||||
.calledWithExactly('applied-ops:1234567890abcdef')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an existing subscription for this redis client', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should subscribe to the redis channel again', function (ctx) {
|
||||
ctx.rclient.subscribe.callCount.should.equal(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when subscribe errors', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rclient.subscribe = sinon
|
||||
.stub()
|
||||
.onFirstCall()
|
||||
.rejects(new Error('some redis error'))
|
||||
.onSecondCall()
|
||||
.resolves()
|
||||
const p = ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
p.then(() =>
|
||||
reject(new Error('should not subscribe but fail'))
|
||||
).catch(err => {
|
||||
err.message.should.equal('failed to subscribe to channel')
|
||||
err.cause.message.should.equal('some redis error')
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
// subscribe is wrapped in Promise, delay other assertions
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should have recorded the error', function (ctx) {
|
||||
expect(
|
||||
ctx.metrics.inc.calledWithExactly('subscribe.failed.applied-ops')
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
it('should subscribe again', function (ctx) {
|
||||
ctx.rclient.subscribe.callCount.should.equal(2)
|
||||
})
|
||||
|
||||
it('should cleanup', function (ctx) {
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when subscribe errors and the clientChannelMap entry was replaced', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rclient.subscribe = sinon
|
||||
.stub()
|
||||
.onFirstCall()
|
||||
.rejects(new Error('some redis error'))
|
||||
.onSecondCall()
|
||||
.resolves()
|
||||
ctx.first = ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
// ignore error
|
||||
ctx.first.catch(() => {})
|
||||
expect(
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient).get(
|
||||
'applied-ops:1234567890abcdef'
|
||||
)
|
||||
).to.equal(ctx.first)
|
||||
|
||||
ctx.rclient.unsubscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.unsubscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
ctx.second = ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
// should get replaced immediately
|
||||
expect(
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient).get(
|
||||
'applied-ops:1234567890abcdef'
|
||||
)
|
||||
).to.equal(ctx.second)
|
||||
|
||||
// let the first subscribe error -> unsubscribe -> subscribe
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should cleanup the second subscribePromise', function (ctx) {
|
||||
expect(
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient).has(
|
||||
'applied-ops:1234567890abcdef'
|
||||
)
|
||||
).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an existing subscription for another redis client but not this one', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.other_rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.other_rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
ctx.rclient.subscribe = sinon.stub().resolves() // discard the original stub
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should subscribe to the redis channel on this redis client', function (ctx) {
|
||||
ctx.rclient.subscribe
|
||||
.calledWithExactly('applied-ops:1234567890abcdef')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribe', function () {
|
||||
describe('when there is no existing subscription for this redis client', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.rclient.unsubscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.unsubscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should unsubscribe from the redis channel', function (ctx) {
|
||||
ctx.rclient.unsubscribe.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an existing subscription for this another redis client but not this one', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.other_rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.rclient.unsubscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.other_rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
ctx.ChannelManager.unsubscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should still unsubscribe from the redis channel on this client', function (ctx) {
|
||||
ctx.rclient.unsubscribe.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when unsubscribe errors and completes', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
ctx.rclient.unsubscribe = sinon
|
||||
.stub()
|
||||
.rejects(new Error('some redis error'))
|
||||
ctx.ChannelManager.unsubscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('should have cleaned up', function (ctx) {
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
})
|
||||
|
||||
it('should not error out when subscribing again', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const p = ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
p.then(() => resolve()).catch(reject)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when unsubscribe errors and another client subscribes at the same time', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
let rejectSubscribe
|
||||
ctx.rclient.unsubscribe = () =>
|
||||
new Promise((resolve, reject) => (rejectSubscribe = reject))
|
||||
ctx.ChannelManager.unsubscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
// delay, actualUnsubscribe should not see the new subscribe request
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
.then(() => setTimeout(resolve))
|
||||
.catch(reject)
|
||||
setTimeout(() =>
|
||||
// delay, rejectSubscribe is not defined immediately
|
||||
rejectSubscribe(new Error('redis error'))
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should have recorded the error', function (ctx) {
|
||||
expect(
|
||||
ctx.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops')
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
it('should have subscribed', function (ctx) {
|
||||
ctx.rclient.subscribe.called.should.equal(true)
|
||||
})
|
||||
|
||||
it('should have discarded the finished Promise', function (ctx) {
|
||||
ctx.ChannelManager.getClientMapEntry(ctx.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an existing subscription for this redis client', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rclient.subscribe = sinon.stub().resolves()
|
||||
ctx.rclient.unsubscribe = sinon.stub().resolves()
|
||||
ctx.ChannelManager.subscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
ctx.ChannelManager.unsubscribe(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(resolve)
|
||||
})
|
||||
})
|
||||
|
||||
it('should unsubscribe from the redis channel', function (ctx) {
|
||||
ctx.rclient.unsubscribe
|
||||
.calledWithExactly('applied-ops:1234567890abcdef')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('publish', function () {
|
||||
describe("when the channel is 'all'", function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rclient.publish = sinon.stub()
|
||||
ctx.ChannelManager.publish(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'all',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
it('should publish on the base channel', function (ctx) {
|
||||
ctx.rclient.publish
|
||||
.calledWithExactly('applied-ops', 'random-message')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the channel has an specific id', function () {
|
||||
describe('when the individual channel setting is false', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rclient.publish = sinon.stub()
|
||||
ctx.settings.publishOnIndividualChannels = false
|
||||
ctx.ChannelManager.publish(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
it('should publish on the per-id channel', function (ctx) {
|
||||
ctx.rclient.publish
|
||||
.calledWithExactly('applied-ops', 'random-message')
|
||||
.should.equal(true)
|
||||
ctx.rclient.publish.calledOnce.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the individual channel setting is true', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rclient.publish = sinon.stub()
|
||||
ctx.settings.publishOnIndividualChannels = true
|
||||
ctx.ChannelManager.publish(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
it('should publish on the per-id channel', function (ctx) {
|
||||
ctx.rclient.publish
|
||||
.calledWithExactly('applied-ops:1234567890abcdef', 'random-message')
|
||||
.should.equal(true)
|
||||
ctx.rclient.publish.calledOnce.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('metrics', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rclient.publish = sinon.stub()
|
||||
ctx.ChannelManager.publish(
|
||||
ctx.rclient,
|
||||
'applied-ops',
|
||||
'all',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
it('should track the payload size', function (ctx) {
|
||||
ctx.metrics.summary
|
||||
.calledWithExactly(
|
||||
'redis.publish.applied-ops',
|
||||
'random-message'.length
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,432 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const modulePath = '../../../app/js/ChannelManager.js'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
|
||||
describe('ChannelManager', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient = {}
|
||||
this.other_rclient = {}
|
||||
return (this.ChannelManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.settings = {}),
|
||||
'@overleaf/metrics': (this.metrics = {
|
||||
inc: sinon.stub(),
|
||||
summary: sinon.stub(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
describe('subscribe', function () {
|
||||
describe('when there is no existing subscription for this redis client', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should subscribe to the redis channel', function () {
|
||||
return this.rclient.subscribe
|
||||
.calledWithExactly('applied-ops:1234567890abcdef')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an existing subscription for this redis client', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should subscribe to the redis channel again', function () {
|
||||
return this.rclient.subscribe.callCount.should.equal(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when subscribe errors', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon
|
||||
.stub()
|
||||
.onFirstCall()
|
||||
.rejects(new Error('some redis error'))
|
||||
.onSecondCall()
|
||||
.resolves()
|
||||
const p = this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
p.then(() => done(new Error('should not subscribe but fail'))).catch(
|
||||
err => {
|
||||
err.message.should.equal('failed to subscribe to channel')
|
||||
err.cause.message.should.equal('some redis error')
|
||||
this.ChannelManager.getClientMapEntry(this.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
// subscribe is wrapped in Promise, delay other assertions
|
||||
return setTimeout(done)
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
it('should have recorded the error', function () {
|
||||
return expect(
|
||||
this.metrics.inc.calledWithExactly('subscribe.failed.applied-ops')
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
it('should subscribe again', function () {
|
||||
return this.rclient.subscribe.callCount.should.equal(2)
|
||||
})
|
||||
|
||||
return it('should cleanup', function () {
|
||||
return this.ChannelManager.getClientMapEntry(this.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when subscribe errors and the clientChannelMap entry was replaced', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon
|
||||
.stub()
|
||||
.onFirstCall()
|
||||
.rejects(new Error('some redis error'))
|
||||
.onSecondCall()
|
||||
.resolves()
|
||||
this.first = this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
// ignore error
|
||||
this.first.catch(() => {})
|
||||
expect(
|
||||
this.ChannelManager.getClientMapEntry(this.rclient).get(
|
||||
'applied-ops:1234567890abcdef'
|
||||
)
|
||||
).to.equal(this.first)
|
||||
|
||||
this.rclient.unsubscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.unsubscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
this.second = this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
// should get replaced immediately
|
||||
expect(
|
||||
this.ChannelManager.getClientMapEntry(this.rclient).get(
|
||||
'applied-ops:1234567890abcdef'
|
||||
)
|
||||
).to.equal(this.second)
|
||||
|
||||
// let the first subscribe error -> unsubscribe -> subscribe
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should cleanup the second subscribePromise', function () {
|
||||
return expect(
|
||||
this.ChannelManager.getClientMapEntry(this.rclient).has(
|
||||
'applied-ops:1234567890abcdef'
|
||||
)
|
||||
).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when there is an existing subscription for another redis client but not this one', function () {
|
||||
beforeEach(function (done) {
|
||||
this.other_rclient.subscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.other_rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
this.rclient.subscribe = sinon.stub().resolves() // discard the original stub
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should subscribe to the redis channel on this redis client', function () {
|
||||
return this.rclient.subscribe
|
||||
.calledWithExactly('applied-ops:1234567890abcdef')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribe', function () {
|
||||
describe('when there is no existing subscription for this redis client', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.unsubscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.unsubscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should unsubscribe from the redis channel', function () {
|
||||
return this.rclient.unsubscribe.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is an existing subscription for this another redis client but not this one', function () {
|
||||
beforeEach(function (done) {
|
||||
this.other_rclient.subscribe = sinon.stub().resolves()
|
||||
this.rclient.unsubscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.other_rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
this.ChannelManager.unsubscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should still unsubscribe from the redis channel on this client', function () {
|
||||
return this.rclient.unsubscribe.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when unsubscribe errors and completes', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
this.rclient.unsubscribe = sinon
|
||||
.stub()
|
||||
.rejects(new Error('some redis error'))
|
||||
this.ChannelManager.unsubscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
setTimeout(done)
|
||||
return null
|
||||
})
|
||||
|
||||
it('should have cleaned up', function () {
|
||||
return this.ChannelManager.getClientMapEntry(this.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
})
|
||||
|
||||
return it('should not error out when subscribing again', function (done) {
|
||||
const p = this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
p.then(() => done()).catch(done)
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when unsubscribe errors and another client subscribes at the same time', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
let rejectSubscribe
|
||||
this.rclient.unsubscribe = () =>
|
||||
new Promise((resolve, reject) => (rejectSubscribe = reject))
|
||||
this.ChannelManager.unsubscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
// delay, actualUnsubscribe should not see the new subscribe request
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
.then(() => setTimeout(done))
|
||||
.catch(done)
|
||||
return setTimeout(() =>
|
||||
// delay, rejectSubscribe is not defined immediately
|
||||
rejectSubscribe(new Error('redis error'))
|
||||
)
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
it('should have recorded the error', function () {
|
||||
return expect(
|
||||
this.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops')
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
it('should have subscribed', function () {
|
||||
return this.rclient.subscribe.called.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should have discarded the finished Promise', function () {
|
||||
return this.ChannelManager.getClientMapEntry(this.rclient)
|
||||
.has('applied-ops:1234567890abcdef')
|
||||
.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when there is an existing subscription for this redis client', function () {
|
||||
beforeEach(function (done) {
|
||||
this.rclient.subscribe = sinon.stub().resolves()
|
||||
this.rclient.unsubscribe = sinon.stub().resolves()
|
||||
this.ChannelManager.subscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
this.ChannelManager.unsubscribe(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef'
|
||||
)
|
||||
return setTimeout(done)
|
||||
})
|
||||
|
||||
return it('should unsubscribe from the redis channel', function () {
|
||||
return this.rclient.unsubscribe
|
||||
.calledWithExactly('applied-ops:1234567890abcdef')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('publish', function () {
|
||||
describe("when the channel is 'all'", function () {
|
||||
beforeEach(function () {
|
||||
this.rclient.publish = sinon.stub()
|
||||
return this.ChannelManager.publish(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'all',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
return it('should publish on the base channel', function () {
|
||||
return this.rclient.publish
|
||||
.calledWithExactly('applied-ops', 'random-message')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the channel has an specific id', function () {
|
||||
describe('when the individual channel setting is false', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient.publish = sinon.stub()
|
||||
this.settings.publishOnIndividualChannels = false
|
||||
return this.ChannelManager.publish(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
return it('should publish on the per-id channel', function () {
|
||||
this.rclient.publish
|
||||
.calledWithExactly('applied-ops', 'random-message')
|
||||
.should.equal(true)
|
||||
return this.rclient.publish.calledOnce.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the individual channel setting is true', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient.publish = sinon.stub()
|
||||
this.settings.publishOnIndividualChannels = true
|
||||
return this.ChannelManager.publish(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'1234567890abcdef',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
return it('should publish on the per-id channel', function () {
|
||||
this.rclient.publish
|
||||
.calledWithExactly('applied-ops:1234567890abcdef', 'random-message')
|
||||
.should.equal(true)
|
||||
return this.rclient.publish.calledOnce.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('metrics', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient.publish = sinon.stub()
|
||||
return this.ChannelManager.publish(
|
||||
this.rclient,
|
||||
'applied-ops',
|
||||
'all',
|
||||
'random-message'
|
||||
)
|
||||
})
|
||||
|
||||
return it('should track the payload size', function () {
|
||||
return this.metrics.summary
|
||||
.calledWithExactly(
|
||||
'redis.publish.applied-ops',
|
||||
'random-message'.length
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,688 @@
|
||||
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
|
||||
import path from 'node:path'
|
||||
import sinon from 'sinon'
|
||||
import tk from 'timekeeper'
|
||||
|
||||
const modulePath = path.join(
|
||||
import.meta.dirname,
|
||||
'../../../app/js/ConnectedUsersManager'
|
||||
)
|
||||
|
||||
describe('ConnectedUsersManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
tk.freeze(new Date())
|
||||
ctx.settings = {
|
||||
redis: {
|
||||
realtime: {
|
||||
key_schema: {
|
||||
clientsInProject({ project_id: projectId }) {
|
||||
return `clients_in_project:${projectId}`
|
||||
},
|
||||
connectedUser({ project_id: projectId, client_id: clientId }) {
|
||||
return `connected_user:${projectId}:${clientId}`
|
||||
},
|
||||
projectNotEmptySince({ projectId }) {
|
||||
return `projectNotEmptySince:{${projectId}}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx.rClient = {
|
||||
auth() {},
|
||||
getdel: sinon.stub(),
|
||||
scard: sinon.stub(),
|
||||
set: sinon.stub(),
|
||||
setex: sinon.stub(),
|
||||
sadd: sinon.stub(),
|
||||
get: sinon.stub(),
|
||||
srem: sinon.stub(),
|
||||
del: sinon.stub(),
|
||||
smembers: sinon.stub(),
|
||||
expire: sinon.stub(),
|
||||
hset: sinon.stub(),
|
||||
hgetall: sinon.stub(),
|
||||
exec: sinon.stub(),
|
||||
multi: () => {
|
||||
return ctx.rClient
|
||||
},
|
||||
}
|
||||
ctx.Metrics = {
|
||||
inc: sinon.stub(),
|
||||
histogram: sinon.stub(),
|
||||
}
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: ctx.settings,
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: ctx.Metrics,
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/redis-wrapper', () => ({
|
||||
default: {
|
||||
createClient: () => {
|
||||
return ctx.rClient
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.ConnectedUsersManager = (await import(modulePath)).default
|
||||
ctx.client_id = '32132132'
|
||||
ctx.project_id = 'dskjh2u21321'
|
||||
ctx.user = {
|
||||
_id: 'user-id-123',
|
||||
first_name: 'Joe',
|
||||
last_name: 'Bloggs',
|
||||
email: 'joe@example.com',
|
||||
}
|
||||
ctx.cursorData = {
|
||||
row: 12,
|
||||
column: 9,
|
||||
doc_id: '53c3b8c85fee64000023dc6e',
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tk.reset()
|
||||
})
|
||||
|
||||
describe('updateUserPosition', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rClient.exec.yields(null, [1, 1])
|
||||
})
|
||||
|
||||
it('should set a key with the date and give it a ttl', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
'last_updated_at',
|
||||
Date.now()
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set a key with the user_id', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
'user_id',
|
||||
ctx.user._id
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set a key with the first_name', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
'first_name',
|
||||
ctx.user.first_name
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set a key with the last_name', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
'last_name',
|
||||
ctx.user.last_name
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set a key with the email', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
'email',
|
||||
ctx.user.email
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should push the client_id on to the project list', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.sadd
|
||||
.calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should add a ttl to the project set so it stays clean', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.expire
|
||||
.calledWith(
|
||||
`clients_in_project:${ctx.project_id}`,
|
||||
24 * 4 * 60 * 60
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should add a ttl to the connected user so it stays clean', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.expire
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
60 * 15
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set the cursor position when provided', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
ctx.cursorData,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${ctx.project_id}:${ctx.client_id}`,
|
||||
'cursorData',
|
||||
JSON.stringify(ctx.cursorData)
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editing_session_mode', function () {
|
||||
const cases = {
|
||||
'should bump the metric when connecting to empty room': {
|
||||
nConnectedClients: 1,
|
||||
cursorData: null,
|
||||
labels: {
|
||||
method: 'connect',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when connecting to non-empty room': {
|
||||
nConnectedClients: 2,
|
||||
cursorData: null,
|
||||
labels: {
|
||||
method: 'connect',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
'should bump the metric when updating in empty room': {
|
||||
nConnectedClients: 1,
|
||||
cursorData: { row: 42 },
|
||||
labels: {
|
||||
method: 'update',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when updating in non-empty room': {
|
||||
nConnectedClients: 2,
|
||||
cursorData: { row: 42 },
|
||||
labels: {
|
||||
method: 'update',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for (const [
|
||||
name,
|
||||
{ nConnectedClients, cursorData, labels },
|
||||
] of Object.entries(cases)) {
|
||||
it(name, async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
ctx.ConnectedUsersManager.updateUserPosition(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
ctx.user,
|
||||
cursorData,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
expect(ctx.Metrics.inc).to.have.been.calledWith(
|
||||
'editing_session_mode',
|
||||
1,
|
||||
labels
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('markUserAsDisconnected', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rClient.exec.yields(null, [1, 0])
|
||||
})
|
||||
|
||||
it('should remove the user from the set', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.srem
|
||||
.calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete the connected_user string', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.del
|
||||
.calledWith(`connected_user:${ctx.project_id}:${ctx.client_id}`)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should add a ttl to the connected user set so it stays clean', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
ctx.rClient.expire
|
||||
.calledWith(
|
||||
`clients_in_project:${ctx.project_id}`,
|
||||
24 * 4 * 60 * 60
|
||||
)
|
||||
.should.equal(true)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editing_session_mode', function () {
|
||||
const cases = {
|
||||
'should bump the metric when disconnecting from now empty room': {
|
||||
nConnectedClients: 0,
|
||||
labels: {
|
||||
method: 'disconnect',
|
||||
status: 'empty',
|
||||
},
|
||||
},
|
||||
'should bump the metric when disconnecting from now single room': {
|
||||
nConnectedClients: 1,
|
||||
labels: {
|
||||
method: 'disconnect',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when disconnecting from now multi room': {
|
||||
nConnectedClients: 2,
|
||||
labels: {
|
||||
method: 'disconnect',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for (const [name, { nConnectedClients, labels }] of Object.entries(
|
||||
cases
|
||||
)) {
|
||||
it(name, async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
expect(ctx.Metrics.inc).to.have.been.calledWith(
|
||||
'editing_session_mode',
|
||||
1,
|
||||
labels
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('projectNotEmptySince', function () {
|
||||
it('should clear the projectNotEmptySince key when empty and skip metric if not set', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.exec.yields(null, [1, 0])
|
||||
ctx.rClient.getdel.yields(null, '')
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
expect(ctx.rClient.getdel).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${ctx.project_id}}`
|
||||
)
|
||||
expect(ctx.Metrics.histogram).to.not.have.been.called
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
it('should clear the projectNotEmptySince key when empty and record metric if set', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.exec.onFirstCall().yields(null, [1, 0])
|
||||
tk.freeze(1_234_000)
|
||||
ctx.rClient.getdel.yields(null, '1230')
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
expect(ctx.rClient.getdel).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${ctx.project_id}}`
|
||||
)
|
||||
expect(ctx.Metrics.histogram).to.have.been.calledWith(
|
||||
'project_not_empty_since',
|
||||
4,
|
||||
sinon.match.any,
|
||||
{ status: 'empty' }
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
it('should set projectNotEmptySince key when single and skip metric if not set before', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.exec.onFirstCall().yields(null, [1, 1])
|
||||
tk.freeze(1_233_001) // should ceil up
|
||||
ctx.rClient.exec.onSecondCall().yields(null, [''])
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
expect(ctx.rClient.set).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${ctx.project_id}}`,
|
||||
'1234',
|
||||
'NX',
|
||||
'EX',
|
||||
31 * 24 * 60 * 60
|
||||
)
|
||||
expect(ctx.Metrics.histogram).to.not.have.been.called
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
const cases = {
|
||||
'should set projectNotEmptySince key when single and record metric if set before':
|
||||
{
|
||||
nConnectedClients: 1,
|
||||
labels: {
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should set projectNotEmptySince key when multi and record metric if set before':
|
||||
{
|
||||
nConnectedClients: 2,
|
||||
labels: {
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
for (const [name, { nConnectedClients, labels }] of Object.entries(
|
||||
cases
|
||||
)) {
|
||||
it(name, async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
|
||||
tk.freeze(1_235_000)
|
||||
ctx.rClient.exec.onSecondCall().yields(null, ['1230'])
|
||||
ctx.ConnectedUsersManager.markUserAsDisconnected(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
err => {
|
||||
if (err) return reject(err)
|
||||
expect(ctx.rClient.set).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${ctx.project_id}}`,
|
||||
'1235',
|
||||
'NX',
|
||||
'EX',
|
||||
31 * 24 * 60 * 60
|
||||
)
|
||||
expect(ctx.Metrics.histogram).to.have.been.calledWith(
|
||||
'project_not_empty_since',
|
||||
5,
|
||||
sinon.match.any,
|
||||
labels
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getConnectedUser', function () {
|
||||
it('should return a connected user if there is a user object', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const cursorData = JSON.stringify({ cursorData: { row: 1 } })
|
||||
ctx.rClient.hgetall.callsArgWith(1, null, {
|
||||
connected_at: new Date(),
|
||||
user_id: ctx.user._id,
|
||||
last_updated_at: `${Date.now()}`,
|
||||
cursorData,
|
||||
})
|
||||
ctx.ConnectedUsersManager._getConnectedUser(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
(err, result) => {
|
||||
if (err) return reject(err)
|
||||
result.connected.should.equal(true)
|
||||
result.client_id.should.equal(ctx.client_id)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a not connected user if there is no object', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.hgetall.callsArgWith(1, null, null)
|
||||
ctx.ConnectedUsersManager._getConnectedUser(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
(err, result) => {
|
||||
if (err) return reject(err)
|
||||
result.connected.should.equal(false)
|
||||
result.client_id.should.equal(ctx.client_id)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a not connected user if there is an empty object', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.rClient.hgetall.callsArgWith(1, null, {})
|
||||
ctx.ConnectedUsersManager._getConnectedUser(
|
||||
ctx.project_id,
|
||||
ctx.client_id,
|
||||
(err, result) => {
|
||||
if (err) return reject(err)
|
||||
result.connected.should.equal(false)
|
||||
result.client_id.should.equal(ctx.client_id)
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConnectedUsers', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.users = ['1234', '5678', '9123', '8234']
|
||||
ctx.rClient.smembers.callsArgWith(1, null, ctx.users)
|
||||
ctx.ConnectedUsersManager._getConnectedUser = sinon.stub()
|
||||
ctx.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(ctx.project_id, ctx.users[0])
|
||||
.callsArgWith(2, null, {
|
||||
connected: true,
|
||||
client_age: 2,
|
||||
client_id: ctx.users[0],
|
||||
})
|
||||
ctx.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(ctx.project_id, ctx.users[1])
|
||||
.callsArgWith(2, null, {
|
||||
connected: false,
|
||||
client_age: 1,
|
||||
client_id: ctx.users[1],
|
||||
})
|
||||
ctx.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(ctx.project_id, ctx.users[2])
|
||||
.callsArgWith(2, null, {
|
||||
connected: true,
|
||||
client_age: 3,
|
||||
client_id: ctx.users[2],
|
||||
})
|
||||
ctx.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(ctx.project_id, ctx.users[3])
|
||||
.callsArgWith(2, null, {
|
||||
connected: true,
|
||||
client_age: 11,
|
||||
client_id: ctx.users[3],
|
||||
})
|
||||
}) // connected but old
|
||||
|
||||
it('should only return the users in the list which are still in redis and recently updated', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.ConnectedUsersManager.getConnectedUsers(
|
||||
ctx.project_id,
|
||||
(err, users) => {
|
||||
if (err) return reject(err)
|
||||
users.length.should.equal(2)
|
||||
users[0].should.deep.equal({
|
||||
client_id: ctx.users[0],
|
||||
client_age: 2,
|
||||
connected: true,
|
||||
})
|
||||
users[1].should.deep.equal({
|
||||
client_id: ctx.users[2],
|
||||
client_age: 3,
|
||||
connected: true,
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,648 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const assert = require('node:assert')
|
||||
const path = require('node:path')
|
||||
const sinon = require('sinon')
|
||||
const modulePath = path.join(__dirname, '../../../app/js/ConnectedUsersManager')
|
||||
const { expect } = require('chai')
|
||||
const tk = require('timekeeper')
|
||||
|
||||
describe('ConnectedUsersManager', function () {
|
||||
beforeEach(function () {
|
||||
tk.freeze(new Date())
|
||||
this.settings = {
|
||||
redis: {
|
||||
realtime: {
|
||||
key_schema: {
|
||||
clientsInProject({ project_id: projectId }) {
|
||||
return `clients_in_project:${projectId}`
|
||||
},
|
||||
connectedUser({ project_id: projectId, client_id: clientId }) {
|
||||
return `connected_user:${projectId}:${clientId}`
|
||||
},
|
||||
projectNotEmptySince({ projectId }) {
|
||||
return `projectNotEmptySince:{${projectId}}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
this.rClient = {
|
||||
auth() {},
|
||||
getdel: sinon.stub(),
|
||||
scard: sinon.stub(),
|
||||
set: sinon.stub(),
|
||||
setex: sinon.stub(),
|
||||
sadd: sinon.stub(),
|
||||
get: sinon.stub(),
|
||||
srem: sinon.stub(),
|
||||
del: sinon.stub(),
|
||||
smembers: sinon.stub(),
|
||||
expire: sinon.stub(),
|
||||
hset: sinon.stub(),
|
||||
hgetall: sinon.stub(),
|
||||
exec: sinon.stub(),
|
||||
multi: () => {
|
||||
return this.rClient
|
||||
},
|
||||
}
|
||||
this.Metrics = {
|
||||
inc: sinon.stub(),
|
||||
histogram: sinon.stub(),
|
||||
}
|
||||
|
||||
this.ConnectedUsersManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': this.settings,
|
||||
'@overleaf/metrics': this.Metrics,
|
||||
'@overleaf/redis-wrapper': {
|
||||
createClient: () => {
|
||||
return this.rClient
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
this.client_id = '32132132'
|
||||
this.project_id = 'dskjh2u21321'
|
||||
this.user = {
|
||||
_id: 'user-id-123',
|
||||
first_name: 'Joe',
|
||||
last_name: 'Bloggs',
|
||||
email: 'joe@example.com',
|
||||
}
|
||||
return (this.cursorData = {
|
||||
row: 12,
|
||||
column: 9,
|
||||
doc_id: '53c3b8c85fee64000023dc6e',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
return tk.reset()
|
||||
})
|
||||
|
||||
describe('updateUserPosition', function () {
|
||||
beforeEach(function () {
|
||||
this.rClient.exec.yields(null, [1, 1])
|
||||
})
|
||||
|
||||
it('should set a key with the date and give it a ttl', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
'last_updated_at',
|
||||
Date.now()
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set a key with the user_id', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
'user_id',
|
||||
this.user._id
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set a key with the first_name', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
'first_name',
|
||||
this.user.first_name
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set a key with the last_name', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
'last_name',
|
||||
this.user.last_name
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set a key with the email', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
'email',
|
||||
this.user.email
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the client_id on to the project list', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.sadd
|
||||
.calledWith(`clients_in_project:${this.project_id}`, this.client_id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should add a ttl to the project set so it stays clean', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.expire
|
||||
.calledWith(
|
||||
`clients_in_project:${this.project_id}`,
|
||||
24 * 4 * 60 * 60
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should add a ttl to the connected user so it stays clean', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
null,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.expire
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
60 * 15
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the cursor position when provided', function (done) {
|
||||
return this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
this.cursorData,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.hset
|
||||
.calledWith(
|
||||
`connected_user:${this.project_id}:${this.client_id}`,
|
||||
'cursorData',
|
||||
JSON.stringify(this.cursorData)
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('editing_session_mode', function () {
|
||||
const cases = {
|
||||
'should bump the metric when connecting to empty room': {
|
||||
nConnectedClients: 1,
|
||||
cursorData: null,
|
||||
labels: {
|
||||
method: 'connect',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when connecting to non-empty room': {
|
||||
nConnectedClients: 2,
|
||||
cursorData: null,
|
||||
labels: {
|
||||
method: 'connect',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
'should bump the metric when updating in empty room': {
|
||||
nConnectedClients: 1,
|
||||
cursorData: { row: 42 },
|
||||
labels: {
|
||||
method: 'update',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when updating in non-empty room': {
|
||||
nConnectedClients: 2,
|
||||
cursorData: { row: 42 },
|
||||
labels: {
|
||||
method: 'update',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for (const [
|
||||
name,
|
||||
{ nConnectedClients, cursorData, labels },
|
||||
] of Object.entries(cases)) {
|
||||
it(name, function (done) {
|
||||
this.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
this.ConnectedUsersManager.updateUserPosition(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
this.user,
|
||||
cursorData,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.Metrics.inc).to.have.been.calledWith(
|
||||
'editing_session_mode',
|
||||
1,
|
||||
labels
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('markUserAsDisconnected', function () {
|
||||
beforeEach(function () {
|
||||
this.rClient.exec.yields(null, [1, 0])
|
||||
})
|
||||
|
||||
it('should remove the user from the set', function (done) {
|
||||
return this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.srem
|
||||
.calledWith(`clients_in_project:${this.project_id}`, this.client_id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should delete the connected_user string', function (done) {
|
||||
return this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.del
|
||||
.calledWith(`connected_user:${this.project_id}:${this.client_id}`)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should add a ttl to the connected user set so it stays clean', function (done) {
|
||||
return this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
this.rClient.expire
|
||||
.calledWith(
|
||||
`clients_in_project:${this.project_id}`,
|
||||
24 * 4 * 60 * 60
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('editing_session_mode', function () {
|
||||
const cases = {
|
||||
'should bump the metric when disconnecting from now empty room': {
|
||||
nConnectedClients: 0,
|
||||
labels: {
|
||||
method: 'disconnect',
|
||||
status: 'empty',
|
||||
},
|
||||
},
|
||||
'should bump the metric when disconnecting from now single room': {
|
||||
nConnectedClients: 1,
|
||||
labels: {
|
||||
method: 'disconnect',
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should bump the metric when disconnecting from now multi room': {
|
||||
nConnectedClients: 2,
|
||||
labels: {
|
||||
method: 'disconnect',
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for (const [name, { nConnectedClients, labels }] of Object.entries(
|
||||
cases
|
||||
)) {
|
||||
it(name, function (done) {
|
||||
this.rClient.exec.yields(null, [1, nConnectedClients])
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.Metrics.inc).to.have.been.calledWith(
|
||||
'editing_session_mode',
|
||||
1,
|
||||
labels
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('projectNotEmptySince', function () {
|
||||
it('should clear the projectNotEmptySince key when empty and skip metric if not set', function (done) {
|
||||
this.rClient.exec.yields(null, [1, 0])
|
||||
this.rClient.getdel.yields(null, '')
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.getdel).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`
|
||||
)
|
||||
expect(this.Metrics.histogram).to.not.have.been.called
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
it('should clear the projectNotEmptySince key when empty and record metric if set', function (done) {
|
||||
this.rClient.exec.onFirstCall().yields(null, [1, 0])
|
||||
tk.freeze(1_234_000)
|
||||
this.rClient.getdel.yields(null, '1230')
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.getdel).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`
|
||||
)
|
||||
expect(this.Metrics.histogram).to.have.been.calledWith(
|
||||
'project_not_empty_since',
|
||||
4,
|
||||
sinon.match.any,
|
||||
{ status: 'empty' }
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
it('should set projectNotEmptySince key when single and skip metric if not set before', function (done) {
|
||||
this.rClient.exec.onFirstCall().yields(null, [1, 1])
|
||||
tk.freeze(1_233_001) // should ceil up
|
||||
this.rClient.exec.onSecondCall().yields(null, [''])
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.set).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`,
|
||||
'1234',
|
||||
'NX',
|
||||
'EX',
|
||||
31 * 24 * 60 * 60
|
||||
)
|
||||
expect(this.Metrics.histogram).to.not.have.been.called
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
const cases = {
|
||||
'should set projectNotEmptySince key when single and record metric if set before':
|
||||
{
|
||||
nConnectedClients: 1,
|
||||
labels: {
|
||||
status: 'single',
|
||||
},
|
||||
},
|
||||
'should set projectNotEmptySince key when multi and record metric if set before':
|
||||
{
|
||||
nConnectedClients: 2,
|
||||
labels: {
|
||||
status: 'multi',
|
||||
},
|
||||
},
|
||||
}
|
||||
for (const [name, { nConnectedClients, labels }] of Object.entries(
|
||||
cases
|
||||
)) {
|
||||
it(name, function (done) {
|
||||
this.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
|
||||
tk.freeze(1_235_000)
|
||||
this.rClient.exec.onSecondCall().yields(null, ['1230'])
|
||||
this.ConnectedUsersManager.markUserAsDisconnected(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
err => {
|
||||
if (err) return done(err)
|
||||
expect(this.rClient.set).to.have.been.calledWith(
|
||||
`projectNotEmptySince:{${this.project_id}}`,
|
||||
'1235',
|
||||
'NX',
|
||||
'EX',
|
||||
31 * 24 * 60 * 60
|
||||
)
|
||||
expect(this.Metrics.histogram).to.have.been.calledWith(
|
||||
'project_not_empty_since',
|
||||
5,
|
||||
sinon.match.any,
|
||||
labels
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getConnectedUser', function () {
|
||||
it('should return a connected user if there is a user object', function (done) {
|
||||
const cursorData = JSON.stringify({ cursorData: { row: 1 } })
|
||||
this.rClient.hgetall.callsArgWith(1, null, {
|
||||
connected_at: new Date(),
|
||||
user_id: this.user._id,
|
||||
last_updated_at: `${Date.now()}`,
|
||||
cursorData,
|
||||
})
|
||||
return this.ConnectedUsersManager._getConnectedUser(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
(err, result) => {
|
||||
if (err) return done(err)
|
||||
result.connected.should.equal(true)
|
||||
result.client_id.should.equal(this.client_id)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should return a not connected user if there is no object', function (done) {
|
||||
this.rClient.hgetall.callsArgWith(1, null, null)
|
||||
return this.ConnectedUsersManager._getConnectedUser(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
(err, result) => {
|
||||
if (err) return done(err)
|
||||
result.connected.should.equal(false)
|
||||
result.client_id.should.equal(this.client_id)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return a not connected user if there is an empty object', function (done) {
|
||||
this.rClient.hgetall.callsArgWith(1, null, {})
|
||||
return this.ConnectedUsersManager._getConnectedUser(
|
||||
this.project_id,
|
||||
this.client_id,
|
||||
(err, result) => {
|
||||
if (err) return done(err)
|
||||
result.connected.should.equal(false)
|
||||
result.client_id.should.equal(this.client_id)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('getConnectedUsers', function () {
|
||||
beforeEach(function () {
|
||||
this.users = ['1234', '5678', '9123', '8234']
|
||||
this.rClient.smembers.callsArgWith(1, null, this.users)
|
||||
this.ConnectedUsersManager._getConnectedUser = sinon.stub()
|
||||
this.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(this.project_id, this.users[0])
|
||||
.callsArgWith(2, null, {
|
||||
connected: true,
|
||||
client_age: 2,
|
||||
client_id: this.users[0],
|
||||
})
|
||||
this.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(this.project_id, this.users[1])
|
||||
.callsArgWith(2, null, {
|
||||
connected: false,
|
||||
client_age: 1,
|
||||
client_id: this.users[1],
|
||||
})
|
||||
this.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(this.project_id, this.users[2])
|
||||
.callsArgWith(2, null, {
|
||||
connected: true,
|
||||
client_age: 3,
|
||||
client_id: this.users[2],
|
||||
})
|
||||
return this.ConnectedUsersManager._getConnectedUser
|
||||
.withArgs(this.project_id, this.users[3])
|
||||
.callsArgWith(2, null, {
|
||||
connected: true,
|
||||
client_age: 11,
|
||||
client_id: this.users[3],
|
||||
})
|
||||
}) // connected but old
|
||||
|
||||
return it('should only return the users in the list which are still in redis and recently updated', function (done) {
|
||||
return this.ConnectedUsersManager.getConnectedUsers(
|
||||
this.project_id,
|
||||
(err, users) => {
|
||||
if (err) return done(err)
|
||||
users.length.should.equal(2)
|
||||
users[0].should.deep.equal({
|
||||
client_id: this.users[0],
|
||||
client_age: 2,
|
||||
connected: true,
|
||||
})
|
||||
users[1].should.deep.equal({
|
||||
client_id: this.users[2],
|
||||
client_age: 3,
|
||||
connected: true,
|
||||
})
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import { vi, describe, beforeEach, it } from 'vitest'
|
||||
|
||||
import sinon from 'sinon'
|
||||
import MockClient from './helpers/MockClient.js'
|
||||
import path from 'node:path'
|
||||
|
||||
const modulePath = path.join(
|
||||
import.meta.dirname,
|
||||
'../../../app/js/DocumentUpdaterController'
|
||||
)
|
||||
|
||||
describe('DocumentUpdaterController', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.project_id = 'project-id-123'
|
||||
ctx.doc_id = 'doc-id-123'
|
||||
ctx.callback = sinon.stub()
|
||||
ctx.io = { mock: 'socket.io' }
|
||||
ctx.rclient = []
|
||||
ctx.RoomEvents = { on: sinon.stub() }
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {
|
||||
redis: {
|
||||
documentupdater: {
|
||||
key_schema: {
|
||||
pendingUpdates({ doc_id: docId }) {
|
||||
return `PendingUpdates:${docId}`
|
||||
},
|
||||
},
|
||||
},
|
||||
pubsub: null,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/RedisClientManager', () => ({
|
||||
default: {
|
||||
createClientList: () => {
|
||||
ctx.redis = {
|
||||
createClient: name => {
|
||||
let rclientStub
|
||||
ctx.rclient.push((rclientStub = { name }))
|
||||
return rclientStub
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/SafeJsonParse', () => ({
|
||||
default: (ctx.SafeJsonParse = {
|
||||
parse: (data, cb) => cb(null, JSON.parse(data)),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/EventLogger', () => ({
|
||||
default: (ctx.EventLogger = { checkEventOrder: sinon.stub() }),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/HealthCheckManager', () => ({
|
||||
default: { check: sinon.stub() },
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: (ctx.metrics = {
|
||||
inc: sinon.stub(),
|
||||
histogram: sinon.stub(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/RoomManager', () => ({
|
||||
default: (ctx.RoomManager = {
|
||||
eventSource: sinon.stub().returns(ctx.RoomEvents),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/ChannelManager', () => ({
|
||||
default: (ctx.ChannelManager = {}),
|
||||
}))
|
||||
|
||||
ctx.EditorUpdatesController = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('listenForUpdatesFromDocumentUpdater', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rclient.length = 0 // clear any existing clients
|
||||
ctx.EditorUpdatesController.rclientList = [
|
||||
ctx.redis.createClient('first'),
|
||||
ctx.redis.createClient('second'),
|
||||
]
|
||||
ctx.rclient[0].subscribe = sinon.stub()
|
||||
ctx.rclient[0].on = sinon.stub()
|
||||
ctx.rclient[1].subscribe = sinon.stub()
|
||||
ctx.rclient[1].on = sinon.stub()
|
||||
ctx.EditorUpdatesController.listenForUpdatesFromDocumentUpdater()
|
||||
})
|
||||
|
||||
it('should subscribe to the doc-updater stream', function (ctx) {
|
||||
ctx.rclient[0].subscribe.calledWith('applied-ops').should.equal(true)
|
||||
})
|
||||
|
||||
it('should register a callback to handle updates', function (ctx) {
|
||||
ctx.rclient[0].on.calledWith('message').should.equal(true)
|
||||
})
|
||||
|
||||
it('should subscribe to any additional doc-updater stream', function (ctx) {
|
||||
ctx.rclient[1].subscribe.calledWith('applied-ops').should.equal(true)
|
||||
ctx.rclient[1].on.calledWith('message').should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_processMessageFromDocumentUpdater', function () {
|
||||
describe('with bad JSON', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.SafeJsonParse.parse = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, new Error('oops'))
|
||||
ctx.EditorUpdatesController._processMessageFromDocumentUpdater(
|
||||
ctx.io,
|
||||
'applied-ops',
|
||||
'blah'
|
||||
)
|
||||
})
|
||||
|
||||
it('should log an error', function (ctx) {
|
||||
ctx.logger.error.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with update', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.message = {
|
||||
doc_id: ctx.doc_id,
|
||||
op: { t: 'foo', p: 12 },
|
||||
}
|
||||
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater =
|
||||
sinon.stub()
|
||||
ctx.EditorUpdatesController._processMessageFromDocumentUpdater(
|
||||
ctx.io,
|
||||
'applied-ops',
|
||||
JSON.stringify(ctx.message)
|
||||
)
|
||||
})
|
||||
|
||||
it('should apply the update', function (ctx) {
|
||||
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater
|
||||
.calledWith(ctx.io, ctx.doc_id, ctx.message.op)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.message = {
|
||||
doc_id: ctx.doc_id,
|
||||
error: 'Something went wrong',
|
||||
}
|
||||
ctx.EditorUpdatesController._processErrorFromDocumentUpdater =
|
||||
sinon.stub()
|
||||
ctx.EditorUpdatesController._processMessageFromDocumentUpdater(
|
||||
ctx.io,
|
||||
'applied-ops',
|
||||
JSON.stringify(ctx.message)
|
||||
)
|
||||
})
|
||||
|
||||
it('should process the error', function (ctx) {
|
||||
ctx.EditorUpdatesController._processErrorFromDocumentUpdater
|
||||
.calledWith(ctx.io, ctx.doc_id, ctx.message.error)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_applyUpdateFromDocumentUpdater', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.sourceClient = new MockClient()
|
||||
ctx.otherClients = [new MockClient(), new MockClient()]
|
||||
ctx.update = {
|
||||
op: [{ t: 'foo', p: 12 }],
|
||||
meta: { source: ctx.sourceClient.publicId },
|
||||
v: (ctx.version = 42),
|
||||
doc: ctx.doc_id,
|
||||
}
|
||||
ctx.io.sockets = {
|
||||
clients: sinon
|
||||
.stub()
|
||||
.returns([
|
||||
ctx.sourceClient,
|
||||
...Array.from(ctx.otherClients),
|
||||
ctx.sourceClient,
|
||||
]),
|
||||
}
|
||||
}) // include a duplicate client
|
||||
|
||||
describe('normally', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
|
||||
ctx.io,
|
||||
ctx.doc_id,
|
||||
ctx.update
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a version bump to the source client', function (ctx) {
|
||||
ctx.sourceClient.emit
|
||||
.calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id })
|
||||
.should.equal(true)
|
||||
ctx.sourceClient.emit.calledOnce.should.equal(true)
|
||||
})
|
||||
|
||||
it('should get the clients connected to the document', function (ctx) {
|
||||
ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true)
|
||||
})
|
||||
|
||||
it('should send the full update to the other clients', function (ctx) {
|
||||
Array.from(ctx.otherClients).map(client =>
|
||||
client.emit
|
||||
.calledWith('otUpdateApplied', ctx.update)
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a duplicate op', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.update.dup = true
|
||||
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
|
||||
ctx.io,
|
||||
ctx.doc_id,
|
||||
ctx.update
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a version bump to the source client as usual', function (ctx) {
|
||||
ctx.sourceClient.emit
|
||||
.calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id })
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should not send anything to the other clients (they've already had the op)", function (ctx) {
|
||||
Array.from(ctx.otherClients).map(client =>
|
||||
client.emit.calledWith('otUpdateApplied').should.equal(false)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_processErrorFromDocumentUpdater', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.clients = [new MockClient(), new MockClient()]
|
||||
ctx.io.sockets = { clients: sinon.stub().returns(ctx.clients) }
|
||||
ctx.EditorUpdatesController._processErrorFromDocumentUpdater(
|
||||
ctx.io,
|
||||
ctx.doc_id,
|
||||
'Something went wrong'
|
||||
)
|
||||
})
|
||||
|
||||
it('should log a warning', function (ctx) {
|
||||
ctx.logger.warn.called.should.equal(true)
|
||||
})
|
||||
|
||||
it('should disconnect all clients in that document', function (ctx) {
|
||||
ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true)
|
||||
Array.from(ctx.clients).map(client =>
|
||||
client.disconnect.called.should.equal(true)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,259 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const modulePath = require('node:path').join(
|
||||
__dirname,
|
||||
'../../../app/js/DocumentUpdaterController'
|
||||
)
|
||||
const MockClient = require('./helpers/MockClient')
|
||||
|
||||
describe('DocumentUpdaterController', function () {
|
||||
beforeEach(function () {
|
||||
this.project_id = 'project-id-123'
|
||||
this.doc_id = 'doc-id-123'
|
||||
this.callback = sinon.stub()
|
||||
this.io = { mock: 'socket.io' }
|
||||
this.rclient = []
|
||||
this.RoomEvents = { on: sinon.stub() }
|
||||
this.EditorUpdatesController = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.settings = {
|
||||
redis: {
|
||||
documentupdater: {
|
||||
key_schema: {
|
||||
pendingUpdates({ doc_id: docId }) {
|
||||
return `PendingUpdates:${docId}`
|
||||
},
|
||||
},
|
||||
},
|
||||
pubsub: null,
|
||||
},
|
||||
}),
|
||||
'./RedisClientManager': {
|
||||
createClientList: () => {
|
||||
this.redis = {
|
||||
createClient: name => {
|
||||
let rclientStub
|
||||
this.rclient.push((rclientStub = { name }))
|
||||
return rclientStub
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
'./SafeJsonParse': (this.SafeJsonParse = {
|
||||
parse: (data, cb) => cb(null, JSON.parse(data)),
|
||||
}),
|
||||
'./EventLogger': (this.EventLogger = { checkEventOrder: sinon.stub() }),
|
||||
'./HealthCheckManager': { check: sinon.stub() },
|
||||
'@overleaf/metrics': (this.metrics = {
|
||||
inc: sinon.stub(),
|
||||
histogram: sinon.stub(),
|
||||
}),
|
||||
'./RoomManager': (this.RoomManager = {
|
||||
eventSource: sinon.stub().returns(this.RoomEvents),
|
||||
}),
|
||||
'./ChannelManager': (this.ChannelManager = {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('listenForUpdatesFromDocumentUpdater', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient.length = 0 // clear any existing clients
|
||||
this.EditorUpdatesController.rclientList = [
|
||||
this.redis.createClient('first'),
|
||||
this.redis.createClient('second'),
|
||||
]
|
||||
this.rclient[0].subscribe = sinon.stub()
|
||||
this.rclient[0].on = sinon.stub()
|
||||
this.rclient[1].subscribe = sinon.stub()
|
||||
this.rclient[1].on = sinon.stub()
|
||||
this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater()
|
||||
})
|
||||
|
||||
it('should subscribe to the doc-updater stream', function () {
|
||||
this.rclient[0].subscribe.calledWith('applied-ops').should.equal(true)
|
||||
})
|
||||
|
||||
it('should register a callback to handle updates', function () {
|
||||
this.rclient[0].on.calledWith('message').should.equal(true)
|
||||
})
|
||||
|
||||
it('should subscribe to any additional doc-updater stream', function () {
|
||||
this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true)
|
||||
this.rclient[1].on.calledWith('message').should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_processMessageFromDocumentUpdater', function () {
|
||||
describe('with bad JSON', function () {
|
||||
beforeEach(function () {
|
||||
this.SafeJsonParse.parse = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, new Error('oops'))
|
||||
return this.EditorUpdatesController._processMessageFromDocumentUpdater(
|
||||
this.io,
|
||||
'applied-ops',
|
||||
'blah'
|
||||
)
|
||||
})
|
||||
|
||||
it('should log an error', function () {
|
||||
return this.logger.error.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with update', function () {
|
||||
beforeEach(function () {
|
||||
this.message = {
|
||||
doc_id: this.doc_id,
|
||||
op: { t: 'foo', p: 12 },
|
||||
}
|
||||
this.EditorUpdatesController._applyUpdateFromDocumentUpdater =
|
||||
sinon.stub()
|
||||
return this.EditorUpdatesController._processMessageFromDocumentUpdater(
|
||||
this.io,
|
||||
'applied-ops',
|
||||
JSON.stringify(this.message)
|
||||
)
|
||||
})
|
||||
|
||||
it('should apply the update', function () {
|
||||
return this.EditorUpdatesController._applyUpdateFromDocumentUpdater
|
||||
.calledWith(this.io, this.doc_id, this.message.op)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error', function () {
|
||||
beforeEach(function () {
|
||||
this.message = {
|
||||
doc_id: this.doc_id,
|
||||
error: 'Something went wrong',
|
||||
}
|
||||
this.EditorUpdatesController._processErrorFromDocumentUpdater =
|
||||
sinon.stub()
|
||||
return this.EditorUpdatesController._processMessageFromDocumentUpdater(
|
||||
this.io,
|
||||
'applied-ops',
|
||||
JSON.stringify(this.message)
|
||||
)
|
||||
})
|
||||
|
||||
return it('should process the error', function () {
|
||||
return this.EditorUpdatesController._processErrorFromDocumentUpdater
|
||||
.calledWith(this.io, this.doc_id, this.message.error)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_applyUpdateFromDocumentUpdater', function () {
|
||||
beforeEach(function () {
|
||||
this.sourceClient = new MockClient()
|
||||
this.otherClients = [new MockClient(), new MockClient()]
|
||||
this.update = {
|
||||
op: [{ t: 'foo', p: 12 }],
|
||||
meta: { source: this.sourceClient.publicId },
|
||||
v: (this.version = 42),
|
||||
doc: this.doc_id,
|
||||
}
|
||||
return (this.io.sockets = {
|
||||
clients: sinon
|
||||
.stub()
|
||||
.returns([
|
||||
this.sourceClient,
|
||||
...Array.from(this.otherClients),
|
||||
this.sourceClient,
|
||||
]),
|
||||
})
|
||||
}) // include a duplicate client
|
||||
|
||||
describe('normally', function () {
|
||||
beforeEach(function () {
|
||||
return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(
|
||||
this.io,
|
||||
this.doc_id,
|
||||
this.update
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a version bump to the source client', function () {
|
||||
this.sourceClient.emit
|
||||
.calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id })
|
||||
.should.equal(true)
|
||||
return this.sourceClient.emit.calledOnce.should.equal(true)
|
||||
})
|
||||
|
||||
it('should get the clients connected to the document', function () {
|
||||
return this.io.sockets.clients
|
||||
.calledWith(this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should send the full update to the other clients', function () {
|
||||
return Array.from(this.otherClients).map(client =>
|
||||
client.emit
|
||||
.calledWith('otUpdateApplied', this.update)
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with a duplicate op', function () {
|
||||
beforeEach(function () {
|
||||
this.update.dup = true
|
||||
return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(
|
||||
this.io,
|
||||
this.doc_id,
|
||||
this.update
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a version bump to the source client as usual', function () {
|
||||
return this.sourceClient.emit
|
||||
.calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id })
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it("should not send anything to the other clients (they've already had the op)", function () {
|
||||
return Array.from(this.otherClients).map(client =>
|
||||
client.emit.calledWith('otUpdateApplied').should.equal(false)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('_processErrorFromDocumentUpdater', function () {
|
||||
beforeEach(function () {
|
||||
this.clients = [new MockClient(), new MockClient()]
|
||||
this.io.sockets = { clients: sinon.stub().returns(this.clients) }
|
||||
return this.EditorUpdatesController._processErrorFromDocumentUpdater(
|
||||
this.io,
|
||||
this.doc_id,
|
||||
'Something went wrong'
|
||||
)
|
||||
})
|
||||
|
||||
it('should log a warning', function () {
|
||||
return this.logger.warn.called.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should disconnect all clients in that document', function () {
|
||||
this.io.sockets.clients.calledWith(this.doc_id).should.equal(true)
|
||||
return Array.from(this.clients).map(client =>
|
||||
client.disconnect.called.should.equal(true)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,422 @@
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
import sinon from 'sinon'
|
||||
import { vi, describe, beforeEach, it, afterEach } from 'vitest'
|
||||
import _ from 'lodash'
|
||||
const modulePath = '../../../app/js/DocumentUpdaterManager'
|
||||
|
||||
describe('DocumentUpdaterManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.project_id = 'project-id-923'
|
||||
ctx.doc_id = 'doc-id-394'
|
||||
ctx.lines = ['one', 'two', 'three']
|
||||
ctx.version = 42
|
||||
ctx.settings = {
|
||||
apis: { documentupdater: { url: 'http://doc-updater.example.com' } },
|
||||
redis: {
|
||||
documentupdater: {
|
||||
key_schema: {
|
||||
pendingUpdates({ doc_id: docId }) {
|
||||
return `PendingUpdates:${docId}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
maxUpdateSize: 7 * 1024 * 1024,
|
||||
pendingUpdateListShardCount: 10,
|
||||
}
|
||||
ctx.rclient = { auth() {} }
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: ctx.settings,
|
||||
}))
|
||||
|
||||
vi.doMock('request', () => ({
|
||||
default: (ctx.request = {}),
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/redis-wrapper', () => ({
|
||||
default: { createClient: () => ctx.rclient },
|
||||
}))
|
||||
|
||||
class Timer {
|
||||
done() {}
|
||||
}
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: (ctx.Metrics = {
|
||||
summary: sinon.stub(),
|
||||
Timer,
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.DocumentUpdaterManager = (await import(modulePath)).default
|
||||
}) // avoid modifying JSON object directly
|
||||
|
||||
describe('getDocument', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.callback = sinon.stub()
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.body = JSON.stringify({
|
||||
lines: ctx.lines,
|
||||
version: ctx.version,
|
||||
ops: (ctx.ops = ['mock-op-1', 'mock-op-2']),
|
||||
ranges: (ctx.ranges = { mock: 'ranges' }),
|
||||
})
|
||||
ctx.fromVersion = 2
|
||||
ctx.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 200 }, ctx.body)
|
||||
ctx.DocumentUpdaterManager.getDocument(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.fromVersion,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the document from the document updater', function (ctx) {
|
||||
const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}/doc/${ctx.doc_id}?fromVersion=${ctx.fromVersion}&historyOTSupport=true`
|
||||
ctx.request.get.calledWith(url).should.equal(true)
|
||||
})
|
||||
|
||||
it('should call the callback with the lines, version, ranges and ops', function (ctx) {
|
||||
ctx.callback
|
||||
.calledWith(null, ctx.lines, ctx.version, ctx.ranges, ctx.ops)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document updater API returns an error', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(
|
||||
1,
|
||||
(ctx.error = new Error('something went wrong')),
|
||||
null,
|
||||
null
|
||||
)
|
||||
ctx.DocumentUpdaterManager.getDocument(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.fromVersion,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error to the callback', function (ctx) {
|
||||
ctx.callback.calledWith(ctx.error).should.equal(true)
|
||||
})
|
||||
})
|
||||
;[404, 422].forEach(statusCode =>
|
||||
describe(`when the document updater returns a ${statusCode} status code`, function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode }, '')
|
||||
ctx.DocumentUpdaterManager.getDocument(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.fromVersion,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the callback with an error', function (ctx) {
|
||||
ctx.callback.called.should.equal(true)
|
||||
ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'doc updater could not load requested ops',
|
||||
info: { statusCode },
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
ctx.logger.error.called.should.equal(false)
|
||||
ctx.logger.warn.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
describe('when the document updater returns a failure error code', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 }, '')
|
||||
ctx.DocumentUpdaterManager.getDocument(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.fromVersion,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the callback with an error', function (ctx) {
|
||||
ctx.callback.called.should.equal(true)
|
||||
ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'doc updater returned a non-success status code',
|
||||
info: {
|
||||
action: 'getDocument',
|
||||
statusCode: 500,
|
||||
},
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
ctx.logger.error.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushProjectToMongoAndDelete', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.callback = sinon.stub()
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.del = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 204 }, '')
|
||||
ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete(
|
||||
ctx.project_id,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should delete the project from the document updater', function (ctx) {
|
||||
const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}?background=true`
|
||||
ctx.request.del.calledWith(url).should.equal(true)
|
||||
})
|
||||
|
||||
it('should call the callback with no error', function (ctx) {
|
||||
ctx.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document updater API returns an error', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.del = sinon
|
||||
.stub()
|
||||
.callsArgWith(
|
||||
1,
|
||||
(ctx.error = new Error('something went wrong')),
|
||||
null,
|
||||
null
|
||||
)
|
||||
ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete(
|
||||
ctx.project_id,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error to the callback', function (ctx) {
|
||||
ctx.callback.calledWith(ctx.error).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document updater returns a failure error code', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.del = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 }, '')
|
||||
ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete(
|
||||
ctx.project_id,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the callback with an error', function (ctx) {
|
||||
ctx.callback.called.should.equal(true)
|
||||
ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'doc updater returned a non-success status code',
|
||||
info: {
|
||||
action: 'flushProjectToMongoAndDelete',
|
||||
statusCode: 500,
|
||||
},
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('queueChange', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.change = {
|
||||
doc: '1234567890',
|
||||
op: [{ d: 'test', p: 345 }],
|
||||
v: 789,
|
||||
}
|
||||
ctx.rclient.rpush = sinon.stub().yields()
|
||||
ctx.callback = sinon.stub()
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}`
|
||||
|
||||
ctx.DocumentUpdaterManager._getPendingUpdateListKey = sinon
|
||||
.stub()
|
||||
.returns(ctx.pendingUpdateListKey)
|
||||
ctx.DocumentUpdaterManager.queueChange(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.change,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the change', function (ctx) {
|
||||
ctx.rclient.rpush
|
||||
.calledWith(
|
||||
`PendingUpdates:${ctx.doc_id}`,
|
||||
JSON.stringify(ctx.change)
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should notify the doc updater of the change via the pending-updates-list queue', function (ctx) {
|
||||
ctx.rclient.rpush
|
||||
.calledWith(
|
||||
ctx.pendingUpdateListKey,
|
||||
`${ctx.project_id}:${ctx.doc_id}`
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error talking to redis during rpush', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.rclient.rpush = sinon
|
||||
.stub()
|
||||
.yields(new Error('something went wrong'))
|
||||
ctx.DocumentUpdaterManager.queueChange(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.change,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function (ctx) {
|
||||
ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with null byte corruption', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.stringifyStub = sinon
|
||||
.stub(JSON, 'stringify')
|
||||
.callsFake(() => '["bad bytes! \u0000 <- here"]')
|
||||
ctx.DocumentUpdaterManager.queueChange(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.change,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function (ctx) {
|
||||
ctx.stringifyStub.restore()
|
||||
})
|
||||
|
||||
it('should return an error', function (ctx) {
|
||||
ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
|
||||
})
|
||||
|
||||
it('should not push the change onto the pending-updates-list queue', function (ctx) {
|
||||
ctx.rclient.rpush.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the update is too large', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.change = {
|
||||
op: { p: 12, t: 'update is too large'.repeat(1024 * 400) },
|
||||
}
|
||||
ctx.DocumentUpdaterManager.queueChange(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.change,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function (ctx) {
|
||||
ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
|
||||
})
|
||||
|
||||
it('should add the size to the error', function (ctx) {
|
||||
ctx.callback.args[0][0].info.updateSize.should.equal(7782422)
|
||||
})
|
||||
|
||||
it('should not push the change onto the pending-updates-list queue', function (ctx) {
|
||||
ctx.rclient.rpush.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with invalid keys', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.change = {
|
||||
op: [{ d: 'test', p: 345 }],
|
||||
version: 789, // not a valid key
|
||||
}
|
||||
ctx.DocumentUpdaterManager.queueChange(
|
||||
ctx.project_id,
|
||||
ctx.doc_id,
|
||||
ctx.change,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove the invalid keys from the change', function (ctx) {
|
||||
ctx.rclient.rpush
|
||||
.calledWith(
|
||||
`PendingUpdates:${ctx.doc_id}`,
|
||||
JSON.stringify({ op: ctx.change.op })
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getPendingUpdateListKey', function () {
|
||||
beforeEach(function (ctx) {
|
||||
const keys = _.times(
|
||||
10000,
|
||||
ctx.DocumentUpdaterManager._getPendingUpdateListKey
|
||||
)
|
||||
ctx.keys = _.uniq(keys)
|
||||
})
|
||||
it('should return normal pending updates key', function (ctx) {
|
||||
_.includes(ctx.keys, 'pending-updates-list').should.equal(true)
|
||||
})
|
||||
|
||||
it('should return pending-updates-list-n keys', function (ctx) {
|
||||
_.includes(ctx.keys, 'pending-updates-list-1').should.equal(true)
|
||||
_.includes(ctx.keys, 'pending-updates-list-3').should.equal(true)
|
||||
_.includes(ctx.keys, 'pending-updates-list-9').should.equal(true)
|
||||
})
|
||||
|
||||
it('should not include pending-updates-list-0 key', function (ctx) {
|
||||
_.includes(ctx.keys, 'pending-updates-list-0').should.equal(false)
|
||||
})
|
||||
|
||||
it('should not include maximum as pendingUpdateListShardCount value', function (ctx) {
|
||||
_.includes(ctx.keys, 'pending-updates-list-10').should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,423 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('node:path')
|
||||
const modulePath = '../../../app/js/DocumentUpdaterManager'
|
||||
const _ = require('lodash')
|
||||
|
||||
describe('DocumentUpdaterManager', function () {
|
||||
beforeEach(function () {
|
||||
let Timer
|
||||
this.project_id = 'project-id-923'
|
||||
this.doc_id = 'doc-id-394'
|
||||
this.lines = ['one', 'two', 'three']
|
||||
this.version = 42
|
||||
this.settings = {
|
||||
apis: { documentupdater: { url: 'http://doc-updater.example.com' } },
|
||||
redis: {
|
||||
documentupdater: {
|
||||
key_schema: {
|
||||
pendingUpdates({ doc_id: docId }) {
|
||||
return `PendingUpdates:${docId}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
maxUpdateSize: 7 * 1024 * 1024,
|
||||
pendingUpdateListShardCount: 10,
|
||||
}
|
||||
this.rclient = { auth() {} }
|
||||
|
||||
return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': this.settings,
|
||||
request: (this.request = {}),
|
||||
'@overleaf/redis-wrapper': { createClient: () => this.rclient },
|
||||
'@overleaf/metrics': (this.Metrics = {
|
||||
summary: sinon.stub(),
|
||||
Timer: (Timer = class Timer {
|
||||
done() {}
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
}) // avoid modifying JSON object directly
|
||||
|
||||
describe('getDocument', function () {
|
||||
beforeEach(function () {
|
||||
return (this.callback = sinon.stub())
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(function () {
|
||||
this.body = JSON.stringify({
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: (this.ops = ['mock-op-1', 'mock-op-2']),
|
||||
ranges: (this.ranges = { mock: 'ranges' }),
|
||||
})
|
||||
this.fromVersion = 2
|
||||
this.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 200 }, this.body)
|
||||
return this.DocumentUpdaterManager.getDocument(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.fromVersion,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the document from the document updater', function () {
|
||||
const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}&historyOTSupport=true`
|
||||
return this.request.get.calledWith(url).should.equal(true)
|
||||
})
|
||||
|
||||
return it('should call the callback with the lines, version, ranges and ops', function () {
|
||||
return this.callback
|
||||
.calledWith(null, this.lines, this.version, this.ranges, this.ops)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document updater API returns an error', function () {
|
||||
beforeEach(function () {
|
||||
this.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(
|
||||
1,
|
||||
(this.error = new Error('something went wrong')),
|
||||
null,
|
||||
null
|
||||
)
|
||||
return this.DocumentUpdaterManager.getDocument(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.fromVersion,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return an error to the callback', function () {
|
||||
return this.callback.calledWith(this.error).should.equal(true)
|
||||
})
|
||||
})
|
||||
;[404, 422].forEach(statusCode =>
|
||||
describe(`when the document updater returns a ${statusCode} status code`, function () {
|
||||
beforeEach(function () {
|
||||
this.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode }, '')
|
||||
return this.DocumentUpdaterManager.getDocument(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.fromVersion,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return the callback with an error', function () {
|
||||
this.callback.called.should.equal(true)
|
||||
this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'doc updater could not load requested ops',
|
||||
info: { statusCode },
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
this.logger.error.called.should.equal(false)
|
||||
this.logger.warn.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return describe('when the document updater returns a failure error code', function () {
|
||||
beforeEach(function () {
|
||||
this.request.get = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 }, '')
|
||||
return this.DocumentUpdaterManager.getDocument(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.fromVersion,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return the callback with an error', function () {
|
||||
this.callback.called.should.equal(true)
|
||||
this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'doc updater returned a non-success status code',
|
||||
info: {
|
||||
action: 'getDocument',
|
||||
statusCode: 500,
|
||||
},
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
this.logger.error.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushProjectToMongoAndDelete', function () {
|
||||
beforeEach(function () {
|
||||
return (this.callback = sinon.stub())
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(function () {
|
||||
this.request.del = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 204 }, '')
|
||||
return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
|
||||
this.project_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should delete the project from the document updater', function () {
|
||||
const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}?background=true`
|
||||
return this.request.del.calledWith(url).should.equal(true)
|
||||
})
|
||||
|
||||
return it('should call the callback with no error', function () {
|
||||
return this.callback.calledWith(null).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document updater API returns an error', function () {
|
||||
beforeEach(function () {
|
||||
this.request.del = sinon
|
||||
.stub()
|
||||
.callsArgWith(
|
||||
1,
|
||||
(this.error = new Error('something went wrong')),
|
||||
null,
|
||||
null
|
||||
)
|
||||
return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
|
||||
this.project_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return an error to the callback', function () {
|
||||
return this.callback.calledWith(this.error).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the document updater returns a failure error code', function () {
|
||||
beforeEach(function () {
|
||||
this.request.del = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 }, '')
|
||||
return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
|
||||
this.project_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return the callback with an error', function () {
|
||||
this.callback.called.should.equal(true)
|
||||
this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'doc updater returned a non-success status code',
|
||||
info: {
|
||||
action: 'flushProjectToMongoAndDelete',
|
||||
statusCode: 500,
|
||||
},
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('queueChange', function () {
|
||||
beforeEach(function () {
|
||||
this.change = {
|
||||
doc: '1234567890',
|
||||
op: [{ d: 'test', p: 345 }],
|
||||
v: 789,
|
||||
}
|
||||
this.rclient.rpush = sinon.stub().yields()
|
||||
return (this.callback = sinon.stub())
|
||||
})
|
||||
|
||||
describe('successfully', function () {
|
||||
beforeEach(function () {
|
||||
this.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}`
|
||||
|
||||
this.DocumentUpdaterManager._getPendingUpdateListKey = sinon
|
||||
.stub()
|
||||
.returns(this.pendingUpdateListKey)
|
||||
this.DocumentUpdaterManager.queueChange(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.change,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the change', function () {
|
||||
this.rclient.rpush
|
||||
.calledWith(
|
||||
`PendingUpdates:${this.doc_id}`,
|
||||
JSON.stringify(this.change)
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should notify the doc updater of the change via the pending-updates-list queue', function () {
|
||||
this.rclient.rpush
|
||||
.calledWith(
|
||||
this.pendingUpdateListKey,
|
||||
`${this.project_id}:${this.doc_id}`
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with error talking to redis during rpush', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient.rpush = sinon
|
||||
.stub()
|
||||
.yields(new Error('something went wrong'))
|
||||
return this.DocumentUpdaterManager.queueChange(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.change,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return an error', function () {
|
||||
return this.callback
|
||||
.calledWithExactly(sinon.match(Error))
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with null byte corruption', function () {
|
||||
beforeEach(function () {
|
||||
this.stringifyStub = sinon
|
||||
.stub(JSON, 'stringify')
|
||||
.callsFake(() => '["bad bytes! \u0000 <- here"]')
|
||||
return this.DocumentUpdaterManager.queueChange(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.change,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
this.stringifyStub.restore()
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
return this.callback
|
||||
.calledWithExactly(sinon.match(Error))
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should not push the change onto the pending-updates-list queue', function () {
|
||||
return this.rclient.rpush.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the update is too large', function () {
|
||||
beforeEach(function () {
|
||||
this.change = {
|
||||
op: { p: 12, t: 'update is too large'.repeat(1024 * 400) },
|
||||
}
|
||||
return this.DocumentUpdaterManager.queueChange(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.change,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
return this.callback
|
||||
.calledWithExactly(sinon.match(Error))
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should add the size to the error', function () {
|
||||
return this.callback.args[0][0].info.updateSize.should.equal(7782422)
|
||||
})
|
||||
|
||||
return it('should not push the change onto the pending-updates-list queue', function () {
|
||||
return this.rclient.rpush.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with invalid keys', function () {
|
||||
beforeEach(function () {
|
||||
this.change = {
|
||||
op: [{ d: 'test', p: 345 }],
|
||||
version: 789, // not a valid key
|
||||
}
|
||||
return this.DocumentUpdaterManager.queueChange(
|
||||
this.project_id,
|
||||
this.doc_id,
|
||||
this.change,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove the invalid keys from the change', function () {
|
||||
return this.rclient.rpush
|
||||
.calledWith(
|
||||
`PendingUpdates:${this.doc_id}`,
|
||||
JSON.stringify({ op: this.change.op })
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getPendingUpdateListKey', function () {
|
||||
beforeEach(function () {
|
||||
const keys = _.times(
|
||||
10000,
|
||||
this.DocumentUpdaterManager._getPendingUpdateListKey
|
||||
)
|
||||
this.keys = _.uniq(keys)
|
||||
})
|
||||
it('should return normal pending updates key', function () {
|
||||
_.includes(this.keys, 'pending-updates-list').should.equal(true)
|
||||
})
|
||||
|
||||
it('should return pending-updates-list-n keys', function () {
|
||||
_.includes(this.keys, 'pending-updates-list-1').should.equal(true)
|
||||
_.includes(this.keys, 'pending-updates-list-3').should.equal(true)
|
||||
_.includes(this.keys, 'pending-updates-list-9').should.equal(true)
|
||||
})
|
||||
|
||||
it('should not include pending-updates-list-0 key', function () {
|
||||
_.includes(this.keys, 'pending-updates-list-0').should.equal(false)
|
||||
})
|
||||
|
||||
it('should not include maximum as pendingUpdateListShardCount value', function () {
|
||||
_.includes(this.keys, 'pending-updates-list-10').should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
import sinon from 'sinon'
|
||||
import { describe, beforeEach, it } from 'vitest'
|
||||
import path from 'node:path'
|
||||
const modulePath = path.join(
|
||||
import.meta.dirname,
|
||||
'../../../app/js/DrainManager'
|
||||
)
|
||||
|
||||
describe('DrainManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.DrainManager = (await import(modulePath)).default
|
||||
ctx.io = {
|
||||
sockets: {
|
||||
clients: sinon.stub(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('startDrainTimeWindow', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.clients = []
|
||||
for (let i = 0; i <= 5399; i++) {
|
||||
ctx.clients[i] = {
|
||||
id: i,
|
||||
emit: sinon.stub(),
|
||||
}
|
||||
}
|
||||
ctx.io.sockets.clients.returns(ctx.clients)
|
||||
ctx.DrainManager.startDrain = sinon.stub()
|
||||
})
|
||||
|
||||
it('should set a drain rate fast enough', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.DrainManager.startDrainTimeWindow(ctx.io, 9)
|
||||
ctx.DrainManager.startDrain.calledWith(ctx.io, 10).should.equal(true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnectNClients', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.clients = []
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
ctx.clients[i] = {
|
||||
id: i,
|
||||
emit: sinon.stub(),
|
||||
}
|
||||
}
|
||||
ctx.io.sockets.clients.returns(ctx.clients)
|
||||
})
|
||||
|
||||
describe('after first pass', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.DrainManager.reconnectNClients(ctx.io, 3)
|
||||
})
|
||||
|
||||
it('should reconnect the first 3 clients', function (ctx) {
|
||||
;[0, 1, 2].map(i =>
|
||||
ctx.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect any more clients', function (ctx) {
|
||||
;[3, 4, 5, 6, 7, 8, 9].map(i =>
|
||||
ctx.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(false)
|
||||
)
|
||||
})
|
||||
|
||||
describe('after second pass', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.DrainManager.reconnectNClients(ctx.io, 3)
|
||||
})
|
||||
|
||||
it('should reconnect the next 3 clients', function (ctx) {
|
||||
;[3, 4, 5].map(i =>
|
||||
ctx.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect any more clients', function (ctx) {
|
||||
;[6, 7, 8, 9].map(i =>
|
||||
ctx.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect the first 3 clients again', function (ctx) {
|
||||
;[0, 1, 2].map(i => ctx.clients[i].emit.calledOnce.should.equal(true))
|
||||
})
|
||||
|
||||
describe('after final pass', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.DrainManager.reconnectNClients(ctx.io, 100)
|
||||
})
|
||||
|
||||
it('should not reconnect the first 6 clients again', function (ctx) {
|
||||
;[0, 1, 2, 3, 4, 5].map(i =>
|
||||
ctx.clients[i].emit.calledOnce.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should log out that it reached the end', function (ctx) {
|
||||
ctx.logger.info
|
||||
.calledWith('All clients have been told to reconnectGracefully')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('node:path')
|
||||
const modulePath = path.join(__dirname, '../../../app/js/DrainManager')
|
||||
|
||||
describe('DrainManager', function () {
|
||||
beforeEach(function () {
|
||||
this.DrainManager = SandboxedModule.require(modulePath, {})
|
||||
return (this.io = {
|
||||
sockets: {
|
||||
clients: sinon.stub(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('startDrainTimeWindow', function () {
|
||||
beforeEach(function () {
|
||||
this.clients = []
|
||||
for (let i = 0; i <= 5399; i++) {
|
||||
this.clients[i] = {
|
||||
id: i,
|
||||
emit: sinon.stub(),
|
||||
}
|
||||
}
|
||||
this.io.sockets.clients.returns(this.clients)
|
||||
return (this.DrainManager.startDrain = sinon.stub())
|
||||
})
|
||||
|
||||
return it('should set a drain rate fast enough', function (done) {
|
||||
this.DrainManager.startDrainTimeWindow(this.io, 9)
|
||||
this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
return describe('reconnectNClients', function () {
|
||||
beforeEach(function () {
|
||||
this.clients = []
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
this.clients[i] = {
|
||||
id: i,
|
||||
emit: sinon.stub(),
|
||||
}
|
||||
}
|
||||
return this.io.sockets.clients.returns(this.clients)
|
||||
})
|
||||
|
||||
return describe('after first pass', function () {
|
||||
beforeEach(function () {
|
||||
return this.DrainManager.reconnectNClients(this.io, 3)
|
||||
})
|
||||
|
||||
it('should reconnect the first 3 clients', function () {
|
||||
return [0, 1, 2].map(i =>
|
||||
this.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect any more clients', function () {
|
||||
return [3, 4, 5, 6, 7, 8, 9].map(i =>
|
||||
this.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(false)
|
||||
)
|
||||
})
|
||||
|
||||
return describe('after second pass', function () {
|
||||
beforeEach(function () {
|
||||
return this.DrainManager.reconnectNClients(this.io, 3)
|
||||
})
|
||||
|
||||
it('should reconnect the next 3 clients', function () {
|
||||
return [3, 4, 5].map(i =>
|
||||
this.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect any more clients', function () {
|
||||
return [6, 7, 8, 9].map(i =>
|
||||
this.clients[i].emit
|
||||
.calledWith('reconnectGracefully')
|
||||
.should.equal(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not reconnect the first 3 clients again', function () {
|
||||
return [0, 1, 2].map(i =>
|
||||
this.clients[i].emit.calledOnce.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
return describe('after final pass', function () {
|
||||
beforeEach(function () {
|
||||
return this.DrainManager.reconnectNClients(this.io, 100)
|
||||
})
|
||||
|
||||
it('should not reconnect the first 6 clients again', function () {
|
||||
return [0, 1, 2, 3, 4, 5].map(i =>
|
||||
this.clients[i].emit.calledOnce.should.equal(true)
|
||||
)
|
||||
})
|
||||
|
||||
return it('should log out that it reached the end', function () {
|
||||
return this.logger.info
|
||||
.calledWith('All clients have been told to reconnectGracefully')
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
|
||||
|
||||
import sinon from 'sinon'
|
||||
import tk from 'timekeeper'
|
||||
const modulePath = '../../../app/js/EventLogger'
|
||||
|
||||
describe('EventLogger', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.start = Date.now()
|
||||
tk.freeze(new Date(ctx.start))
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: (ctx.metrics = { inc: sinon.stub() }),
|
||||
}))
|
||||
|
||||
ctx.EventLogger = (await import(modulePath)).default
|
||||
ctx.channel = 'applied-ops'
|
||||
ctx.id_1 = 'random-hostname:abc-1'
|
||||
ctx.message_1 = 'message-1'
|
||||
ctx.id_2 = 'random-hostname:abc-2'
|
||||
ctx.message_2 = 'message-2'
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tk.reset()
|
||||
})
|
||||
|
||||
describe('checkEventOrder', function () {
|
||||
describe('when the events are in order', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
|
||||
ctx.status = ctx.EventLogger.checkEventOrder(
|
||||
ctx.channel,
|
||||
ctx.id_2,
|
||||
ctx.message_2
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept events in order', function (ctx) {
|
||||
expect(ctx.status).to.be.undefined
|
||||
})
|
||||
|
||||
it('should increment the valid event metric', function (ctx) {
|
||||
ctx.metrics.inc
|
||||
.calledWith(`event.${ctx.channel}.valid`)
|
||||
.should.equals(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is a duplicate events', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
|
||||
ctx.status = ctx.EventLogger.checkEventOrder(
|
||||
ctx.channel,
|
||||
ctx.id_1,
|
||||
ctx.message_1
|
||||
)
|
||||
})
|
||||
|
||||
it('should return "duplicate" for the same event', function (ctx) {
|
||||
expect(ctx.status).to.equal('duplicate')
|
||||
})
|
||||
|
||||
it('should increment the duplicate event metric', function (ctx) {
|
||||
ctx.metrics.inc
|
||||
.calledWith(`event.${ctx.channel}.duplicate`)
|
||||
.should.equals(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are out of order events', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
|
||||
ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_2, ctx.message_2)
|
||||
ctx.status = ctx.EventLogger.checkEventOrder(
|
||||
ctx.channel,
|
||||
ctx.id_1,
|
||||
ctx.message_1
|
||||
)
|
||||
})
|
||||
|
||||
it('should return "out-of-order" for the event', function (ctx) {
|
||||
expect(ctx.status).to.equal('out-of-order')
|
||||
})
|
||||
|
||||
it('should increment the out-of-order event metric', function (ctx) {
|
||||
ctx.metrics.inc
|
||||
.calledWith(`event.${ctx.channel}.out-of-order`)
|
||||
.should.equals(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('after MAX_STALE_TIME_IN_MS', function () {
|
||||
it('should flush old entries', function (ctx) {
|
||||
let status
|
||||
ctx.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10
|
||||
ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
status = ctx.EventLogger.checkEventOrder(
|
||||
ctx.channel,
|
||||
ctx.id_1,
|
||||
ctx.message_1
|
||||
)
|
||||
expect(status).to.equal('duplicate')
|
||||
}
|
||||
// the next event should flush the old entries aboce
|
||||
ctx.EventLogger.MAX_STALE_TIME_IN_MS = 1000
|
||||
tk.freeze(new Date(ctx.start + 5 * 1000))
|
||||
// because we flushed the entries this should not be a duplicate
|
||||
ctx.EventLogger.checkEventOrder(ctx.channel, 'other-1', ctx.message_2)
|
||||
status = ctx.EventLogger.checkEventOrder(
|
||||
ctx.channel,
|
||||
ctx.id_1,
|
||||
ctx.message_1
|
||||
)
|
||||
expect(status).to.be.undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const modulePath = '../../../app/js/EventLogger'
|
||||
const sinon = require('sinon')
|
||||
const tk = require('timekeeper')
|
||||
|
||||
describe('EventLogger', function () {
|
||||
beforeEach(function () {
|
||||
this.start = Date.now()
|
||||
tk.freeze(new Date(this.start))
|
||||
this.EventLogger = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/metrics': (this.metrics = { inc: sinon.stub() }),
|
||||
},
|
||||
})
|
||||
this.channel = 'applied-ops'
|
||||
this.id_1 = 'random-hostname:abc-1'
|
||||
this.message_1 = 'message-1'
|
||||
this.id_2 = 'random-hostname:abc-2'
|
||||
return (this.message_2 = 'message-2')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
return tk.reset()
|
||||
})
|
||||
|
||||
return describe('checkEventOrder', function () {
|
||||
describe('when the events are in order', function () {
|
||||
beforeEach(function () {
|
||||
this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
)
|
||||
return (this.status = this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_2,
|
||||
this.message_2
|
||||
))
|
||||
})
|
||||
|
||||
it('should accept events in order', function () {
|
||||
return expect(this.status).to.be.undefined
|
||||
})
|
||||
|
||||
return it('should increment the valid event metric', function () {
|
||||
return this.metrics.inc
|
||||
.calledWith(`event.${this.channel}.valid`)
|
||||
.should.equals(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there is a duplicate events', function () {
|
||||
beforeEach(function () {
|
||||
this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
)
|
||||
return (this.status = this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
))
|
||||
})
|
||||
|
||||
it('should return "duplicate" for the same event', function () {
|
||||
return expect(this.status).to.equal('duplicate')
|
||||
})
|
||||
|
||||
return it('should increment the duplicate event metric', function () {
|
||||
return this.metrics.inc
|
||||
.calledWith(`event.${this.channel}.duplicate`)
|
||||
.should.equals(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are out of order events', function () {
|
||||
beforeEach(function () {
|
||||
this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
)
|
||||
this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_2,
|
||||
this.message_2
|
||||
)
|
||||
return (this.status = this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
))
|
||||
})
|
||||
|
||||
it('should return "out-of-order" for the event', function () {
|
||||
return expect(this.status).to.equal('out-of-order')
|
||||
})
|
||||
|
||||
return it('should increment the out-of-order event metric', function () {
|
||||
return this.metrics.inc
|
||||
.calledWith(`event.${this.channel}.out-of-order`)
|
||||
.should.equals(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('after MAX_STALE_TIME_IN_MS', function () {
|
||||
return it('should flush old entries', function () {
|
||||
let status
|
||||
this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10
|
||||
this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
)
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
status = this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
)
|
||||
expect(status).to.equal('duplicate')
|
||||
}
|
||||
// the next event should flush the old entries aboce
|
||||
this.EventLogger.MAX_STALE_TIME_IN_MS = 1000
|
||||
tk.freeze(new Date(this.start + 5 * 1000))
|
||||
// because we flushed the entries this should not be a duplicate
|
||||
this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
'other-1',
|
||||
this.message_2
|
||||
)
|
||||
status = this.EventLogger.checkEventOrder(
|
||||
this.channel,
|
||||
this.id_1,
|
||||
this.message_1
|
||||
)
|
||||
return expect(status).to.be.undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,403 @@
|
||||
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
|
||||
|
||||
import sinon from 'sinon'
|
||||
const modulePath = '../../../app/js/RoomManager.js'
|
||||
|
||||
describe('RoomManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.project_id = 'project-id-123'
|
||||
ctx.doc_id = 'doc-id-456'
|
||||
ctx.other_doc_id = 'doc-id-789'
|
||||
ctx.client = { namespace: { name: '' }, id: 'first-client' }
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {}),
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: (ctx.metrics = { gauge: sinon.stub() }),
|
||||
}))
|
||||
|
||||
ctx.RoomManager = (await import(modulePath)).default
|
||||
ctx.RoomManager._clientsInRoom = sinon.stub()
|
||||
ctx.RoomManager._clientAlreadyInRoom = sinon.stub()
|
||||
ctx.RoomEvents = ctx.RoomManager.eventSource()
|
||||
sinon.spy(ctx.RoomEvents, 'emit')
|
||||
sinon.spy(ctx.RoomEvents, 'once')
|
||||
})
|
||||
|
||||
describe('emitOnCompletion', function () {
|
||||
describe('when a subscribe errors', function () {
|
||||
afterEach(function (ctx) {
|
||||
process.removeListener('unhandledRejection', ctx.onUnhandled)
|
||||
})
|
||||
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.onUnhandled = error => {
|
||||
ctx.unhandledError = error
|
||||
reject(new Error(`unhandledRejection: ${error.message}`))
|
||||
}
|
||||
process.on('unhandledRejection', ctx.onUnhandled)
|
||||
|
||||
let rejectSubscribePromise
|
||||
const subscribePromise = new Promise(
|
||||
// eslint-disable-next-line promise/param-names
|
||||
(_, r) => (rejectSubscribePromise = r)
|
||||
)
|
||||
const promises = [subscribePromise]
|
||||
const eventName = 'project-subscribed-123'
|
||||
ctx.RoomEvents.once(eventName, () => setTimeout(resolve, 100))
|
||||
ctx.RoomManager.emitOnCompletion(promises, eventName)
|
||||
setTimeout(() =>
|
||||
rejectSubscribePromise(new Error('subscribe failed'))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep going', function (ctx) {
|
||||
expect(ctx.unhandledError).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinProject', function () {
|
||||
describe('when the project room is empty', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.project_id)
|
||||
.onFirstCall()
|
||||
.returns(0)
|
||||
ctx.client.join = sinon.stub()
|
||||
ctx.callback = sinon.stub()
|
||||
ctx.RoomEvents.on('project-active', id => {
|
||||
setTimeout(() => {
|
||||
ctx.RoomEvents.emit(`project-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
ctx.RoomManager.joinProject(ctx.client, ctx.project_id, err => {
|
||||
ctx.callback(err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit a 'project-active' event with the id", function (ctx) {
|
||||
ctx.RoomEvents.emit
|
||||
.calledWithExactly('project-active', ctx.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should listen for the 'project-subscribed-id' event", function (ctx) {
|
||||
ctx.RoomEvents.once
|
||||
.calledWith(`project-subscribed-${ctx.project_id}`)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should join the room using the id', function (ctx) {
|
||||
ctx.client.join.calledWithExactly(ctx.project_id).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are other clients in the project room', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.project_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(124)
|
||||
ctx.client.join = sinon.stub()
|
||||
ctx.RoomManager.joinProject(ctx.client, ctx.project_id, err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should join the room using the id', function (ctx) {
|
||||
ctx.client.join.called.should.equal(true)
|
||||
})
|
||||
|
||||
it('should not emit any events', function (ctx) {
|
||||
ctx.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinDoc', function () {
|
||||
describe('when the doc room is empty', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onFirstCall()
|
||||
.returns(0)
|
||||
ctx.client.join = sinon.stub()
|
||||
ctx.callback = sinon.stub()
|
||||
ctx.RoomEvents.on('doc-active', id => {
|
||||
setTimeout(() => {
|
||||
ctx.RoomEvents.emit(`doc-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, err => {
|
||||
ctx.callback(err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit a 'doc-active' event with the id", function (ctx) {
|
||||
ctx.RoomEvents.emit
|
||||
.calledWithExactly('doc-active', ctx.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should listen for the 'doc-subscribed-id' event", function (ctx) {
|
||||
ctx.RoomEvents.once
|
||||
.calledWith(`doc-subscribed-${ctx.doc_id}`)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should join the room using the id', function (ctx) {
|
||||
ctx.client.join.calledWithExactly(ctx.doc_id).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are other clients in the doc room', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(124)
|
||||
ctx.client.join = sinon.stub()
|
||||
ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should join the room using the id', function (ctx) {
|
||||
ctx.client.join.called.should.equal(true)
|
||||
})
|
||||
|
||||
it('should not emit any events', function (ctx) {
|
||||
ctx.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveDoc', function () {
|
||||
describe('when doc room will be empty after this client has left', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.returns(true)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
ctx.client.leave = sinon.stub()
|
||||
ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id)
|
||||
})
|
||||
|
||||
it('should leave the room using the id', function (ctx) {
|
||||
ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
|
||||
})
|
||||
|
||||
it("should emit a 'doc-empty' event with the id", function (ctx) {
|
||||
ctx.RoomEvents.emit
|
||||
.calledWithExactly('doc-empty', ctx.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are other clients in the doc room', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.returns(true)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onCall(0)
|
||||
.returns(123)
|
||||
ctx.client.leave = sinon.stub()
|
||||
ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id)
|
||||
})
|
||||
|
||||
it('should leave the room using the id', function (ctx) {
|
||||
ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
|
||||
})
|
||||
|
||||
it('should not emit any events', function (ctx) {
|
||||
ctx.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the client is not in the doc room', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.returns(false)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
ctx.client.leave = sinon.stub()
|
||||
ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id)
|
||||
})
|
||||
|
||||
it('should not leave the room', function (ctx) {
|
||||
ctx.client.leave.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should not emit any events', function (ctx) {
|
||||
ctx.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveProjectAndDocs', function () {
|
||||
describe('when the client is connected to the project and multiple docs', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.RoomManager._roomsClientIsIn = sinon
|
||||
.stub()
|
||||
.returns([ctx.project_id, ctx.doc_id, ctx.other_doc_id])
|
||||
ctx.client.join = sinon.stub()
|
||||
ctx.client.leave = sinon.stub()
|
||||
})
|
||||
|
||||
describe('when this is the only client connected', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
// first call is for the join,
|
||||
// second for the leave
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
.onCall(1)
|
||||
.returns(0)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.other_doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
.onCall(1)
|
||||
.returns(0)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.project_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
.onCall(1)
|
||||
.returns(0)
|
||||
ctx.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.returns(true)
|
||||
.withArgs(ctx.client, ctx.other_doc_id)
|
||||
.returns(true)
|
||||
.withArgs(ctx.client, ctx.project_id)
|
||||
.returns(true)
|
||||
ctx.RoomEvents.on('project-active', id => {
|
||||
setTimeout(() => {
|
||||
ctx.RoomEvents.emit(`project-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
ctx.RoomEvents.on('doc-active', id => {
|
||||
setTimeout(() => {
|
||||
ctx.RoomEvents.emit(`doc-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
// put the client in the rooms
|
||||
ctx.RoomManager.joinProject(ctx.client, ctx.project_id, () => {
|
||||
ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, () => {
|
||||
ctx.RoomManager.joinDoc(ctx.client, ctx.other_doc_id, () => {
|
||||
// now leave the project
|
||||
ctx.RoomManager.leaveProjectAndDocs(ctx.client)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave all the docs', function (ctx) {
|
||||
ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
|
||||
ctx.client.leave
|
||||
.calledWithExactly(ctx.other_doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should leave the project', function (ctx) {
|
||||
ctx.client.leave.calledWithExactly(ctx.project_id).should.equal(true)
|
||||
})
|
||||
|
||||
it("should emit a 'doc-empty' event with the id for each doc", function (ctx) {
|
||||
ctx.RoomEvents.emit
|
||||
.calledWithExactly('doc-empty', ctx.doc_id)
|
||||
.should.equal(true)
|
||||
ctx.RoomEvents.emit
|
||||
.calledWithExactly('doc-empty', ctx.other_doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should emit a 'project-empty' event with the id for the project", function (ctx) {
|
||||
ctx.RoomEvents.emit
|
||||
.calledWithExactly('project-empty', ctx.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when other clients are still connected', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(122)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.other_doc_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(122)
|
||||
ctx.RoomManager._clientsInRoom
|
||||
.withArgs(ctx.client, ctx.project_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(122)
|
||||
ctx.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(ctx.client, ctx.doc_id)
|
||||
.returns(true)
|
||||
.withArgs(ctx.client, ctx.other_doc_id)
|
||||
.returns(true)
|
||||
.withArgs(ctx.client, ctx.project_id)
|
||||
.returns(true)
|
||||
ctx.RoomManager.leaveProjectAndDocs(ctx.client)
|
||||
})
|
||||
|
||||
it('should leave all the docs', function (ctx) {
|
||||
ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
|
||||
ctx.client.leave
|
||||
.calledWithExactly(ctx.other_doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should leave the project', function (ctx) {
|
||||
ctx.client.leave.calledWithExactly(ctx.project_id).should.equal(true)
|
||||
})
|
||||
|
||||
it('should not emit any events', function (ctx) {
|
||||
ctx.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,412 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
promise/param-names,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const modulePath = '../../../app/js/RoomManager.js'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
|
||||
describe('RoomManager', function () {
|
||||
beforeEach(function () {
|
||||
this.project_id = 'project-id-123'
|
||||
this.doc_id = 'doc-id-456'
|
||||
this.other_doc_id = 'doc-id-789'
|
||||
this.client = { namespace: { name: '' }, id: 'first-client' }
|
||||
this.RoomManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.settings = {}),
|
||||
'@overleaf/metrics': (this.metrics = { gauge: sinon.stub() }),
|
||||
},
|
||||
})
|
||||
this.RoomManager._clientsInRoom = sinon.stub()
|
||||
this.RoomManager._clientAlreadyInRoom = sinon.stub()
|
||||
this.RoomEvents = this.RoomManager.eventSource()
|
||||
sinon.spy(this.RoomEvents, 'emit')
|
||||
return sinon.spy(this.RoomEvents, 'once')
|
||||
})
|
||||
|
||||
describe('emitOnCompletion', function () {
|
||||
return describe('when a subscribe errors', function () {
|
||||
afterEach(function () {
|
||||
return process.removeListener('unhandledRejection', this.onUnhandled)
|
||||
})
|
||||
|
||||
beforeEach(function (done) {
|
||||
this.onUnhandled = error => {
|
||||
this.unhandledError = error
|
||||
return done(new Error(`unhandledRejection: ${error.message}`))
|
||||
}
|
||||
process.on('unhandledRejection', this.onUnhandled)
|
||||
|
||||
let reject
|
||||
const subscribePromise = new Promise((_, r) => (reject = r))
|
||||
const promises = [subscribePromise]
|
||||
const eventName = 'project-subscribed-123'
|
||||
this.RoomEvents.once(eventName, () => setTimeout(done, 100))
|
||||
this.RoomManager.emitOnCompletion(promises, eventName)
|
||||
return setTimeout(() => reject(new Error('subscribe failed')))
|
||||
})
|
||||
|
||||
return it('should keep going', function () {
|
||||
return expect(this.unhandledError).to.not.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinProject', function () {
|
||||
describe('when the project room is empty', function () {
|
||||
beforeEach(function (done) {
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.project_id)
|
||||
.onFirstCall()
|
||||
.returns(0)
|
||||
this.client.join = sinon.stub()
|
||||
this.callback = sinon.stub()
|
||||
this.RoomEvents.on('project-active', id => {
|
||||
return setTimeout(() => {
|
||||
return this.RoomEvents.emit(`project-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
return this.RoomManager.joinProject(
|
||||
this.client,
|
||||
this.project_id,
|
||||
err => {
|
||||
this.callback(err)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("should emit a 'project-active' event with the id", function () {
|
||||
return this.RoomEvents.emit
|
||||
.calledWithExactly('project-active', this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should listen for the 'project-subscribed-id' event", function () {
|
||||
return this.RoomEvents.once
|
||||
.calledWith(`project-subscribed-${this.project_id}`)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should join the room using the id', function () {
|
||||
return this.client.join
|
||||
.calledWithExactly(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when there are other clients in the project room', function () {
|
||||
beforeEach(function (done) {
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.project_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(124)
|
||||
this.client.join = sinon.stub()
|
||||
this.RoomManager.joinProject(this.client, this.project_id, done)
|
||||
})
|
||||
|
||||
it('should join the room using the id', function () {
|
||||
return this.client.join.called.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should not emit any events', function () {
|
||||
return this.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinDoc', function () {
|
||||
describe('when the doc room is empty', function () {
|
||||
beforeEach(function (done) {
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onFirstCall()
|
||||
.returns(0)
|
||||
this.client.join = sinon.stub()
|
||||
this.callback = sinon.stub()
|
||||
this.RoomEvents.on('doc-active', id => {
|
||||
return setTimeout(() => {
|
||||
return this.RoomEvents.emit(`doc-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
return this.RoomManager.joinDoc(this.client, this.doc_id, err => {
|
||||
this.callback(err)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit a 'doc-active' event with the id", function () {
|
||||
return this.RoomEvents.emit
|
||||
.calledWithExactly('doc-active', this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should listen for the 'doc-subscribed-id' event", function () {
|
||||
return this.RoomEvents.once
|
||||
.calledWith(`doc-subscribed-${this.doc_id}`)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should join the room using the id', function () {
|
||||
return this.client.join
|
||||
.calledWithExactly(this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when there are other clients in the doc room', function () {
|
||||
beforeEach(function (done) {
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(124)
|
||||
this.client.join = sinon.stub()
|
||||
this.RoomManager.joinDoc(this.client, this.doc_id, done)
|
||||
})
|
||||
|
||||
it('should join the room using the id', function () {
|
||||
return this.client.join.called.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should not emit any events', function () {
|
||||
return this.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveDoc', function () {
|
||||
describe('when doc room will be empty after this client has left', function () {
|
||||
beforeEach(function () {
|
||||
this.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.returns(true)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
this.client.leave = sinon.stub()
|
||||
return this.RoomManager.leaveDoc(this.client, this.doc_id)
|
||||
})
|
||||
|
||||
it('should leave the room using the id', function () {
|
||||
return this.client.leave
|
||||
.calledWithExactly(this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it("should emit a 'doc-empty' event with the id", function () {
|
||||
return this.RoomEvents.emit
|
||||
.calledWithExactly('doc-empty', this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are other clients in the doc room', function () {
|
||||
beforeEach(function () {
|
||||
this.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.returns(true)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onCall(0)
|
||||
.returns(123)
|
||||
this.client.leave = sinon.stub()
|
||||
return this.RoomManager.leaveDoc(this.client, this.doc_id)
|
||||
})
|
||||
|
||||
it('should leave the room using the id', function () {
|
||||
return this.client.leave
|
||||
.calledWithExactly(this.doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should not emit any events', function () {
|
||||
return this.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the client is not in the doc room', function () {
|
||||
beforeEach(function () {
|
||||
this.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.returns(false)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
this.client.leave = sinon.stub()
|
||||
return this.RoomManager.leaveDoc(this.client, this.doc_id)
|
||||
})
|
||||
|
||||
it('should not leave the room', function () {
|
||||
return this.client.leave.called.should.equal(false)
|
||||
})
|
||||
|
||||
return it('should not emit any events', function () {
|
||||
return this.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return describe('leaveProjectAndDocs', function () {
|
||||
return describe('when the client is connected to the project and multiple docs', function () {
|
||||
beforeEach(function () {
|
||||
this.RoomManager._roomsClientIsIn = sinon
|
||||
.stub()
|
||||
.returns([this.project_id, this.doc_id, this.other_doc_id])
|
||||
this.client.join = sinon.stub()
|
||||
return (this.client.leave = sinon.stub())
|
||||
})
|
||||
|
||||
describe('when this is the only client connected', function () {
|
||||
beforeEach(function (done) {
|
||||
// first call is for the join,
|
||||
// second for the leave
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
.onCall(1)
|
||||
.returns(0)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.other_doc_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
.onCall(1)
|
||||
.returns(0)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.project_id)
|
||||
.onCall(0)
|
||||
.returns(0)
|
||||
.onCall(1)
|
||||
.returns(0)
|
||||
this.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.returns(true)
|
||||
.withArgs(this.client, this.other_doc_id)
|
||||
.returns(true)
|
||||
.withArgs(this.client, this.project_id)
|
||||
.returns(true)
|
||||
this.RoomEvents.on('project-active', id => {
|
||||
return setTimeout(() => {
|
||||
return this.RoomEvents.emit(`project-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
this.RoomEvents.on('doc-active', id => {
|
||||
return setTimeout(() => {
|
||||
return this.RoomEvents.emit(`doc-subscribed-${id}`)
|
||||
}, 100)
|
||||
})
|
||||
// put the client in the rooms
|
||||
return this.RoomManager.joinProject(
|
||||
this.client,
|
||||
this.project_id,
|
||||
() => {
|
||||
return this.RoomManager.joinDoc(this.client, this.doc_id, () => {
|
||||
return this.RoomManager.joinDoc(
|
||||
this.client,
|
||||
this.other_doc_id,
|
||||
() => {
|
||||
// now leave the project
|
||||
this.RoomManager.leaveProjectAndDocs(this.client)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should leave all the docs', function () {
|
||||
this.client.leave.calledWithExactly(this.doc_id).should.equal(true)
|
||||
return this.client.leave
|
||||
.calledWithExactly(this.other_doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should leave the project', function () {
|
||||
return this.client.leave
|
||||
.calledWithExactly(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it("should emit a 'doc-empty' event with the id for each doc", function () {
|
||||
this.RoomEvents.emit
|
||||
.calledWithExactly('doc-empty', this.doc_id)
|
||||
.should.equal(true)
|
||||
return this.RoomEvents.emit
|
||||
.calledWithExactly('doc-empty', this.other_doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it("should emit a 'project-empty' event with the id for the project", function () {
|
||||
return this.RoomEvents.emit
|
||||
.calledWithExactly('project-empty', this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when other clients are still connected', function () {
|
||||
beforeEach(function () {
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(122)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.other_doc_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(122)
|
||||
this.RoomManager._clientsInRoom
|
||||
.withArgs(this.client, this.project_id)
|
||||
.onFirstCall()
|
||||
.returns(123)
|
||||
.onSecondCall()
|
||||
.returns(122)
|
||||
this.RoomManager._clientAlreadyInRoom
|
||||
.withArgs(this.client, this.doc_id)
|
||||
.returns(true)
|
||||
.withArgs(this.client, this.other_doc_id)
|
||||
.returns(true)
|
||||
.withArgs(this.client, this.project_id)
|
||||
.returns(true)
|
||||
return this.RoomManager.leaveProjectAndDocs(this.client)
|
||||
})
|
||||
|
||||
it('should leave all the docs', function () {
|
||||
this.client.leave.calledWithExactly(this.doc_id).should.equal(true)
|
||||
return this.client.leave
|
||||
.calledWithExactly(this.other_doc_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should leave the project', function () {
|
||||
return this.client.leave
|
||||
.calledWithExactly(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should not emit any events', function () {
|
||||
return this.RoomEvents.emit.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { vi, expect, describe, beforeEach, it } from 'vitest'
|
||||
|
||||
const modulePath = '../../../app/js/SafeJsonParse'
|
||||
|
||||
describe('SafeJsonParse', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.Settings = {
|
||||
maxUpdateSize: 16 * 1024,
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.SafeJsonParse = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('parse', function () {
|
||||
it('should parse documents correctly', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
|
||||
if (error) return reject(error)
|
||||
expect(parsed).to.deep.equal({ foo: 'bar' })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error on bad data', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.SafeJsonParse.parse('blah', (error, parsed) => {
|
||||
expect(error).to.exist
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error on oversized data', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
// we have a 2k overhead on top of max size
|
||||
const bigBlob = Array(16 * 1024).join('A')
|
||||
const data = `{"foo": "${bigBlob}"}`
|
||||
ctx.Settings.maxUpdateSize = 2 * 1024
|
||||
ctx.SafeJsonParse.parse(data, (error, parsed) => {
|
||||
ctx.logger.error.called.should.equal(false)
|
||||
expect(error).to.exist
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-useless-escape,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const modulePath = '../../../app/js/SafeJsonParse'
|
||||
|
||||
describe('SafeJsonParse', function () {
|
||||
beforeEach(function () {
|
||||
return (this.SafeJsonParse = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.Settings = {
|
||||
maxUpdateSize: 16 * 1024,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return describe('parse', function () {
|
||||
it('should parse documents correctly', function (done) {
|
||||
return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
|
||||
if (error) return done(error)
|
||||
expect(parsed).to.deep.equal({ foo: 'bar' })
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error on bad data', function (done) {
|
||||
return this.SafeJsonParse.parse('blah', (error, parsed) => {
|
||||
expect(error).to.exist
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
return it('should return an error on oversized data', function (done) {
|
||||
// we have a 2k overhead on top of max size
|
||||
const bigBlob = Array(16 * 1024).join('A')
|
||||
const data = `{\"foo\": \"${bigBlob}\"}`
|
||||
this.Settings.maxUpdateSize = 2 * 1024
|
||||
return this.SafeJsonParse.parse(data, (error, parsed) => {
|
||||
this.logger.error.called.should.equal(false)
|
||||
expect(error).to.exist
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { vi, expect, describe, beforeEach, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import sinon from 'sinon'
|
||||
const modulePath = '../../../app/js/SessionSockets'
|
||||
|
||||
describe('SessionSockets', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.metrics = { inc: sinon.stub() }
|
||||
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: ctx.metrics,
|
||||
}))
|
||||
|
||||
ctx.SessionSocketsModule = (await import(modulePath)).default
|
||||
ctx.io = new EventEmitter()
|
||||
ctx.id1 = Math.random().toString()
|
||||
ctx.id2 = Math.random().toString()
|
||||
const redisResponses = {
|
||||
error: [new Error('Redis: something went wrong'), null],
|
||||
unknownId: [null, null],
|
||||
}
|
||||
redisResponses[ctx.id1] = [null, { user: { _id: '123' } }]
|
||||
redisResponses[ctx.id2] = [null, { user: { _id: 'abc' } }]
|
||||
|
||||
ctx.sessionStore = {
|
||||
get: sinon
|
||||
.stub()
|
||||
.callsFake((id, fn) => fn.apply(null, redisResponses[id])),
|
||||
}
|
||||
ctx.cookieParser = function (req, res, next) {
|
||||
req.signedCookies = req._signedCookies
|
||||
return next()
|
||||
}
|
||||
ctx.SessionSockets = ctx.SessionSocketsModule(
|
||||
ctx.io,
|
||||
ctx.sessionStore,
|
||||
ctx.cookieParser,
|
||||
'ol.sid'
|
||||
)
|
||||
ctx.checkSocket = (socket, fn) => {
|
||||
ctx.SessionSockets.once('connection', fn)
|
||||
return ctx.io.emit('connection', socket)
|
||||
}
|
||||
})
|
||||
|
||||
describe('without cookies', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = { handshake: {} }
|
||||
})
|
||||
|
||||
it('should return a lookup error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(false)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status "none"', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'none',
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a different cookie', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = { handshake: { _signedCookies: { other: 1 } } }
|
||||
})
|
||||
|
||||
it('should return a lookup error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(false)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a cookie with an invalid signature', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': false } },
|
||||
}
|
||||
})
|
||||
|
||||
it('should return a lookup error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(false)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=bad-signature', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'bad-signature',
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and a failing session lookup', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': 'error' } },
|
||||
}
|
||||
})
|
||||
|
||||
it('should query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a redis error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('Redis: something went wrong')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'error',
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and no matching session', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': 'unknownId' } },
|
||||
}
|
||||
})
|
||||
|
||||
it('should query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a lookup error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=missing', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'missing',
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and a matching session', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': ctx.id1 } },
|
||||
}
|
||||
})
|
||||
|
||||
it('should query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not return an error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.not.exist
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the session', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, (error, s, session) => {
|
||||
if (error) return reject(error)
|
||||
expect(session).to.deep.equal({ user: { _id: '123' } })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=signed', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'signed',
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a different valid cookie and matching session', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': ctx.id2 } },
|
||||
}
|
||||
})
|
||||
|
||||
it('should query redis', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.sessionStore.get.called).to.equal(true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not return an error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, error => {
|
||||
expect(error).to.not.exist
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the other session', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, (error, s, session) => {
|
||||
if (error) return reject(error)
|
||||
expect(session).to.deep.equal({ user: { _id: 'abc' } })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=error', async function (ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.checkSocket(ctx.socket, () => {
|
||||
expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'signed',
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,280 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { EventEmitter } = require('node:events')
|
||||
const { expect } = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const modulePath = '../../../app/js/SessionSockets'
|
||||
const sinon = require('sinon')
|
||||
|
||||
describe('SessionSockets', function () {
|
||||
beforeEach(function () {
|
||||
this.metrics = { inc: sinon.stub() }
|
||||
this.SessionSocketsModule = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/metrics': this.metrics,
|
||||
},
|
||||
})
|
||||
this.io = new EventEmitter()
|
||||
this.id1 = Math.random().toString()
|
||||
this.id2 = Math.random().toString()
|
||||
const redisResponses = {
|
||||
error: [new Error('Redis: something went wrong'), null],
|
||||
unknownId: [null, null],
|
||||
}
|
||||
redisResponses[this.id1] = [null, { user: { _id: '123' } }]
|
||||
redisResponses[this.id2] = [null, { user: { _id: 'abc' } }]
|
||||
|
||||
this.sessionStore = {
|
||||
get: sinon
|
||||
.stub()
|
||||
.callsFake((id, fn) => fn.apply(null, redisResponses[id])),
|
||||
}
|
||||
this.cookieParser = function (req, res, next) {
|
||||
req.signedCookies = req._signedCookies
|
||||
return next()
|
||||
}
|
||||
this.SessionSockets = this.SessionSocketsModule(
|
||||
this.io,
|
||||
this.sessionStore,
|
||||
this.cookieParser,
|
||||
'ol.sid'
|
||||
)
|
||||
return (this.checkSocket = (socket, fn) => {
|
||||
this.SessionSockets.once('connection', fn)
|
||||
return this.io.emit('connection', socket)
|
||||
})
|
||||
})
|
||||
|
||||
describe('without cookies', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = { handshake: {} })
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(false)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status "none"', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'none',
|
||||
})
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a different cookie', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = { handshake: { _signedCookies: { other: 1 } } })
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(false)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a cookie with an invalid signature', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': false } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(false)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=bad-signature', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'bad-signature',
|
||||
})
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and a failing session lookup', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': 'error' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a redis error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('Redis: something went wrong')
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=error', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'error',
|
||||
})
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and no matching session', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': 'unknownId' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('could not look up session by key')
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=missing', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'missing',
|
||||
})
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and a matching session', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': this.id1 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not return an error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.not.exist
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the session', function (done) {
|
||||
return this.checkSocket(this.socket, (error, s, session) => {
|
||||
if (error) return done(error)
|
||||
expect(session).to.deep.equal({ user: { _id: '123' } })
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=signed', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'signed',
|
||||
})
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a different valid cookie and matching session', function () {
|
||||
beforeEach(function () {
|
||||
return (this.socket = {
|
||||
handshake: { _signedCookies: { 'ol.sid': this.id2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should query redis', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.sessionStore.get.called).to.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not return an error', function (done) {
|
||||
return this.checkSocket(this.socket, error => {
|
||||
expect(error).to.not.exist
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the other session', function (done) {
|
||||
return this.checkSocket(this.socket, (error, s, session) => {
|
||||
if (error) return done(error)
|
||||
expect(session).to.deep.equal({ user: { _id: 'abc' } })
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should increment the session.cookie metric with status=error', function (done) {
|
||||
return this.checkSocket(this.socket, () => {
|
||||
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
|
||||
status: 'signed',
|
||||
})
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { vi, describe, beforeEach, it } from 'vitest'
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
import sinon from 'sinon'
|
||||
|
||||
const modulePath = '../../../app/js/WebApiManager.js'
|
||||
|
||||
describe('WebApiManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.project_id = 'project-id-123'
|
||||
ctx.user_id = 'user-id-123'
|
||||
ctx.user = { _id: ctx.user_id }
|
||||
ctx.callback = sinon.stub()
|
||||
|
||||
vi.doMock('request', () => ({
|
||||
default: (ctx.request = {}),
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {
|
||||
apis: {
|
||||
web: {
|
||||
url: 'http://web.example.com',
|
||||
user: 'username',
|
||||
pass: 'password',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
return (ctx.WebApiManager = (await import(modulePath)).default)
|
||||
})
|
||||
|
||||
return describe('joinProject', function () {
|
||||
describe('successfully', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.response = {
|
||||
project: { name: 'Test project' },
|
||||
privilegeLevel: 'owner',
|
||||
isRestrictedUser: true,
|
||||
isTokenMember: true,
|
||||
isInvitedMember: true,
|
||||
}
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 200 }, ctx.response)
|
||||
return ctx.WebApiManager.joinProject(
|
||||
ctx.project_id,
|
||||
ctx.user,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a request to web to join the project', function (ctx) {
|
||||
return ctx.request.post
|
||||
.calledWith({
|
||||
url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`,
|
||||
auth: {
|
||||
user: ctx.settings.apis.web.user,
|
||||
pass: ctx.settings.apis.web.pass,
|
||||
sendImmediately: true,
|
||||
},
|
||||
json: {
|
||||
userId: ctx.user_id,
|
||||
anonymousAccessToken: undefined,
|
||||
},
|
||||
jar: false,
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should return the project, privilegeLevel, and restricted flag', function (ctx) {
|
||||
return ctx.callback
|
||||
.calledWith(null, ctx.response.project, ctx.response.privilegeLevel, {
|
||||
isRestrictedUser: ctx.response.isRestrictedUser,
|
||||
isTokenMember: ctx.response.isTokenMember,
|
||||
isInvitedMember: ctx.response.isInvitedMember,
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with anon user', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.user_id = 'anonymous-user'
|
||||
ctx.token = 'a-ro-token'
|
||||
ctx.user = {
|
||||
_id: ctx.user_id,
|
||||
anonymousAccessToken: ctx.token,
|
||||
}
|
||||
ctx.response = {
|
||||
project: { name: 'Test project' },
|
||||
privilegeLevel: 'readOnly',
|
||||
isRestrictedUser: true,
|
||||
isTokenMember: false,
|
||||
isInvitedMember: false,
|
||||
}
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.yields(null, { statusCode: 200 }, ctx.response)
|
||||
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback)
|
||||
})
|
||||
|
||||
it('should send a request to web to join the project', function (ctx) {
|
||||
ctx.request.post.should.have.been.calledWith({
|
||||
url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`,
|
||||
auth: {
|
||||
user: ctx.settings.apis.web.user,
|
||||
pass: ctx.settings.apis.web.pass,
|
||||
sendImmediately: true,
|
||||
},
|
||||
json: {
|
||||
userId: ctx.user_id,
|
||||
anonymousAccessToken: ctx.token,
|
||||
},
|
||||
jar: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the project, privilegeLevel, and restricted flag', function (ctx) {
|
||||
ctx.callback.should.have.been.calledWith(
|
||||
null,
|
||||
ctx.response.project,
|
||||
ctx.response.privilegeLevel,
|
||||
{
|
||||
isRestrictedUser: ctx.response.isRestrictedUser,
|
||||
isTokenMember: ctx.response.isTokenMember,
|
||||
isInvitedMember: ctx.response.isInvitedMember,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when web replies with a 403', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 403 }, null)
|
||||
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
|
||||
})
|
||||
|
||||
it('should call the callback with an error', function (ctx) {
|
||||
ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'not authorized',
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when web replies with a 404', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 404 }, null)
|
||||
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
|
||||
})
|
||||
|
||||
it('should call the callback with an error', function (ctx) {
|
||||
ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'project not found',
|
||||
info: { code: 'ProjectNotFound' },
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an error from web', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 }, null)
|
||||
return ctx.WebApiManager.joinProject(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should call the callback with an error', function (ctx) {
|
||||
return ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'non-success status code from web',
|
||||
info: { statusCode: 500 },
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with no data from web', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 200 }, null)
|
||||
return ctx.WebApiManager.joinProject(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should call the callback with an error', function (ctx) {
|
||||
return ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'no data returned from joinProject request',
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the project is over its rate limit', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 429 }, null)
|
||||
return ctx.WebApiManager.joinProject(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
ctx.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should call the callback with a TooManyRequests error code', function (ctx) {
|
||||
return ctx.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'rate-limit hit when joining project',
|
||||
info: {
|
||||
code: 'TooManyRequests',
|
||||
},
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,268 +0,0 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const modulePath = '../../../app/js/WebApiManager.js'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const { CodedError } = require('../../../app/js/Errors')
|
||||
|
||||
describe('WebApiManager', function () {
|
||||
beforeEach(function () {
|
||||
this.project_id = 'project-id-123'
|
||||
this.user_id = 'user-id-123'
|
||||
this.user = { _id: this.user_id }
|
||||
this.callback = sinon.stub()
|
||||
return (this.WebApiManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
request: (this.request = {}),
|
||||
'@overleaf/settings': (this.settings = {
|
||||
apis: {
|
||||
web: {
|
||||
url: 'http://web.example.com',
|
||||
user: 'username',
|
||||
pass: 'password',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return describe('joinProject', function () {
|
||||
describe('successfully', function () {
|
||||
beforeEach(function () {
|
||||
this.response = {
|
||||
project: { name: 'Test project' },
|
||||
privilegeLevel: 'owner',
|
||||
isRestrictedUser: true,
|
||||
isTokenMember: true,
|
||||
isInvitedMember: true,
|
||||
}
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 200 }, this.response)
|
||||
return this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a request to web to join the project', function () {
|
||||
return this.request.post
|
||||
.calledWith({
|
||||
url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
|
||||
auth: {
|
||||
user: this.settings.apis.web.user,
|
||||
pass: this.settings.apis.web.pass,
|
||||
sendImmediately: true,
|
||||
},
|
||||
json: {
|
||||
userId: this.user_id,
|
||||
anonymousAccessToken: undefined,
|
||||
},
|
||||
jar: false,
|
||||
})
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should return the project, privilegeLevel, and restricted flag', function () {
|
||||
return this.callback
|
||||
.calledWith(
|
||||
null,
|
||||
this.response.project,
|
||||
this.response.privilegeLevel,
|
||||
{
|
||||
isRestrictedUser: this.response.isRestrictedUser,
|
||||
isTokenMember: this.response.isTokenMember,
|
||||
isInvitedMember: this.response.isInvitedMember,
|
||||
}
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with anon user', function () {
|
||||
beforeEach(function () {
|
||||
this.user_id = 'anonymous-user'
|
||||
this.token = 'a-ro-token'
|
||||
this.user = {
|
||||
_id: this.user_id,
|
||||
anonymousAccessToken: this.token,
|
||||
}
|
||||
this.response = {
|
||||
project: { name: 'Test project' },
|
||||
privilegeLevel: 'readOnly',
|
||||
isRestrictedUser: true,
|
||||
isTokenMember: false,
|
||||
isInvitedMember: false,
|
||||
}
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.yields(null, { statusCode: 200 }, this.response)
|
||||
this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should send a request to web to join the project', function () {
|
||||
this.request.post.should.have.been.calledWith({
|
||||
url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
|
||||
auth: {
|
||||
user: this.settings.apis.web.user,
|
||||
pass: this.settings.apis.web.pass,
|
||||
sendImmediately: true,
|
||||
},
|
||||
json: {
|
||||
userId: this.user_id,
|
||||
anonymousAccessToken: this.token,
|
||||
},
|
||||
jar: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the project, privilegeLevel, and restricted flag', function () {
|
||||
this.callback.should.have.been.calledWith(
|
||||
null,
|
||||
this.response.project,
|
||||
this.response.privilegeLevel,
|
||||
{
|
||||
isRestrictedUser: this.response.isRestrictedUser,
|
||||
isTokenMember: this.response.isTokenMember,
|
||||
isInvitedMember: this.response.isInvitedMember,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when web replies with a 403', function () {
|
||||
beforeEach(function () {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 403 }, null)
|
||||
this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should call the callback with an error', function () {
|
||||
this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'not authorized',
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when web replies with a 404', function () {
|
||||
beforeEach(function () {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 404 }, null)
|
||||
this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should call the callback with an error', function () {
|
||||
this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'project not found',
|
||||
info: { code: 'ProjectNotFound' },
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an error from web', function () {
|
||||
beforeEach(function () {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 }, null)
|
||||
return this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should call the callback with an error', function () {
|
||||
return this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'non-success status code from web',
|
||||
info: { statusCode: 500 },
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with no data from web', function () {
|
||||
beforeEach(function () {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 200 }, null)
|
||||
return this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should call the callback with an error', function () {
|
||||
return this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'no data returned from joinProject request',
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the project is over its rate limit', function () {
|
||||
beforeEach(function () {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 429 }, null)
|
||||
return this.WebApiManager.joinProject(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
return it('should call the callback with a TooManyRequests error code', function () {
|
||||
return this.callback
|
||||
.calledWith(
|
||||
sinon.match({
|
||||
message: 'rate-limit hit when joining project',
|
||||
info: {
|
||||
code: 'TooManyRequests',
|
||||
},
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+27
-28
@@ -1,28 +1,27 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const { expect } = require('chai')
|
||||
const modulePath = require('node:path').join(
|
||||
__dirname,
|
||||
import { expect, describe, beforeEach, it } from 'vitest'
|
||||
import path from 'node:path'
|
||||
|
||||
const modulePath = path.join(
|
||||
import.meta.dirname,
|
||||
'../../../app/js/WebsocketAddressManager'
|
||||
)
|
||||
|
||||
describe('WebsocketAddressManager', function () {
|
||||
beforeEach(function () {
|
||||
this.WebsocketAddressManager = SandboxedModule.require(modulePath, {
|
||||
requires: {},
|
||||
})
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.WebsocketAddressManager = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('with a proxy configuration', function () {
|
||||
beforeEach(function () {
|
||||
this.websocketAddressManager = new this.WebsocketAddressManager(
|
||||
beforeEach(function (ctx) {
|
||||
ctx.websocketAddressManager = new ctx.WebsocketAddressManager(
|
||||
true,
|
||||
'127.0.0.1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the client ip address when behind a proxy', function () {
|
||||
it('should return the client ip address when behind a proxy', function (ctx) {
|
||||
expect(
|
||||
this.websocketAddressManager.getRemoteIp({
|
||||
ctx.websocketAddressManager.getRemoteIp({
|
||||
headers: {
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '123.45.67.89',
|
||||
@@ -32,56 +31,56 @@ describe('WebsocketAddressManager', function () {
|
||||
).to.equal('123.45.67.89')
|
||||
})
|
||||
|
||||
it('should return the client ip address for a direct connection', function () {
|
||||
it('should return the client ip address for a direct connection', function (ctx) {
|
||||
expect(
|
||||
this.websocketAddressManager.getRemoteIp({
|
||||
ctx.websocketAddressManager.getRemoteIp({
|
||||
headers: {},
|
||||
address: { address: '123.45.67.89' },
|
||||
})
|
||||
).to.equal('123.45.67.89')
|
||||
})
|
||||
|
||||
it('should return the client ip address when there are no headers in the handshake', function () {
|
||||
it('should return the client ip address when there are no headers in the handshake', function (ctx) {
|
||||
expect(
|
||||
this.websocketAddressManager.getRemoteIp({
|
||||
ctx.websocketAddressManager.getRemoteIp({
|
||||
address: { address: '123.45.67.89' },
|
||||
})
|
||||
).to.equal('123.45.67.89')
|
||||
})
|
||||
|
||||
it('should return a "client-handshake-missing" response when the handshake is missing', function () {
|
||||
expect(this.websocketAddressManager.getRemoteIp()).to.equal(
|
||||
it('should return a "client-handshake-missing" response when the handshake is missing', function (ctx) {
|
||||
expect(ctx.websocketAddressManager.getRemoteIp()).to.equal(
|
||||
'client-handshake-missing'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('without a proxy configuration', function () {
|
||||
beforeEach(function () {
|
||||
this.websocketAddressManager = new this.WebsocketAddressManager(false)
|
||||
beforeEach(function (ctx) {
|
||||
ctx.websocketAddressManager = new ctx.WebsocketAddressManager(false)
|
||||
})
|
||||
|
||||
it('should return the client ip address for a direct connection', function () {
|
||||
it('should return the client ip address for a direct connection', function (ctx) {
|
||||
expect(
|
||||
this.websocketAddressManager.getRemoteIp({
|
||||
ctx.websocketAddressManager.getRemoteIp({
|
||||
headers: {},
|
||||
address: { address: '123.45.67.89' },
|
||||
})
|
||||
).to.equal('123.45.67.89')
|
||||
})
|
||||
|
||||
it('should return undefined if the client ip address is not present', function () {
|
||||
it('should return undefined if the client ip address is not present', function (ctx) {
|
||||
expect(
|
||||
this.websocketAddressManager.getRemoteIp({
|
||||
ctx.websocketAddressManager.getRemoteIp({
|
||||
headers: {},
|
||||
address: { otherAddressProperty: '123.45.67.89' },
|
||||
})
|
||||
).to.be.undefined
|
||||
})
|
||||
|
||||
it('should return the proxy ip address if there is actually a proxy', function () {
|
||||
it('should return the proxy ip address if there is actually a proxy', function (ctx) {
|
||||
expect(
|
||||
this.websocketAddressManager.getRemoteIp({
|
||||
ctx.websocketAddressManager.getRemoteIp({
|
||||
headers: {
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '123.45.67.89',
|
||||
@@ -91,8 +90,8 @@ describe('WebsocketAddressManager', function () {
|
||||
).to.equal('127.0.0.1')
|
||||
})
|
||||
|
||||
it('should return a "client-handshake-missing" response when the handshake is missing', function () {
|
||||
expect(this.websocketAddressManager.getRemoteIp()).to.equal(
|
||||
it('should return a "client-handshake-missing" response when the handshake is missing', function (ctx) {
|
||||
expect(ctx.websocketAddressManager.getRemoteIp()).to.equal(
|
||||
'client-handshake-missing'
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+181
-168
@@ -1,62 +1,75 @@
|
||||
/* eslint-disable
|
||||
no-return-assign,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const expect = require('chai').expect
|
||||
const modulePath = require('node:path').join(
|
||||
__dirname,
|
||||
import { vi, expect, describe, beforeEach, it } from 'vitest'
|
||||
|
||||
import sinon from 'sinon'
|
||||
import path from 'node:path'
|
||||
|
||||
const modulePath = path.join(
|
||||
import.meta.dirname,
|
||||
'../../../app/js/WebsocketLoadBalancer'
|
||||
)
|
||||
|
||||
describe('WebsocketLoadBalancer', function () {
|
||||
beforeEach(function () {
|
||||
this.rclient = {}
|
||||
this.RoomEvents = { on: sinon.stub() }
|
||||
this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.Settings = { redis: {} }),
|
||||
'./RedisClientManager': {
|
||||
createClientList: () => [],
|
||||
},
|
||||
'./SafeJsonParse': (this.SafeJsonParse = {
|
||||
parse: (data, cb) => cb(null, JSON.parse(data)),
|
||||
}),
|
||||
'./EventLogger': { checkEventOrder: sinon.stub() },
|
||||
'./HealthCheckManager': { check: sinon.stub() },
|
||||
'./RoomManager': (this.RoomManager = {
|
||||
eventSource: sinon.stub().returns(this.RoomEvents),
|
||||
}),
|
||||
'./ChannelManager': (this.ChannelManager = { publish: sinon.stub() }),
|
||||
'./ConnectedUsersManager': (this.ConnectedUsersManager = {
|
||||
refreshClient: sinon.stub(),
|
||||
}),
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.rclient = {}
|
||||
ctx.RoomEvents = { on: sinon.stub() }
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.Settings = { redis: {} }),
|
||||
}))
|
||||
|
||||
vi.doMock('./RedisClientManager', () => ({
|
||||
default: {
|
||||
createClientList: () => [],
|
||||
},
|
||||
})
|
||||
this.io = {}
|
||||
this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
|
||||
this.WebsocketLoadBalancer.rclientSubList = [
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/SafeJsonParse', () => ({
|
||||
default: (ctx.SafeJsonParse = {
|
||||
parse: (data, cb) => cb(null, JSON.parse(data)),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/EventLogger', () => ({
|
||||
default: { checkEventOrder: sinon.stub() },
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/HealthCheckManager', () => ({
|
||||
default: { check: sinon.stub() },
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/RoomManager', () => ({
|
||||
default: (ctx.RoomManager = {
|
||||
eventSource: sinon.stub().returns(ctx.RoomEvents),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/ChannelManager', () => ({
|
||||
default: (ctx.ChannelManager = { publish: sinon.stub() }),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../app/js/ConnectedUsersManager', () => ({
|
||||
default: (ctx.ConnectedUsersManager = {
|
||||
refreshClient: sinon.stub(),
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.WebsocketLoadBalancer = (await import(modulePath)).default
|
||||
ctx.io = {}
|
||||
ctx.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
|
||||
ctx.WebsocketLoadBalancer.rclientSubList = [
|
||||
{
|
||||
subscribe: sinon.stub(),
|
||||
on: sinon.stub(),
|
||||
},
|
||||
]
|
||||
|
||||
this.room_id = 'room-id'
|
||||
this.message = 'otUpdateApplied'
|
||||
return (this.payload = ['argument one', 42])
|
||||
ctx.room_id = 'room-id'
|
||||
ctx.message = 'otUpdateApplied'
|
||||
ctx.payload = ['argument one', 42]
|
||||
})
|
||||
|
||||
describe('shouldDisconnectClient', function () {
|
||||
it('should return false for general messages', function () {
|
||||
it('should return false for general messages', function (ctx) {
|
||||
const client = {
|
||||
ol_context: { user_id: 'abcd' },
|
||||
}
|
||||
@@ -65,7 +78,7 @@ describe('WebsocketLoadBalancer', function () {
|
||||
payload: [{ data: 'whatever' }],
|
||||
}
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(false)
|
||||
})
|
||||
|
||||
@@ -74,7 +87,7 @@ describe('WebsocketLoadBalancer', function () {
|
||||
const client = {
|
||||
ol_context: { user_id: 'abcd' },
|
||||
}
|
||||
it('should return true if the user id matches', function () {
|
||||
it('should return true if the user id matches', function (ctx) {
|
||||
const message = {
|
||||
message: messageName,
|
||||
payload: [
|
||||
@@ -84,10 +97,10 @@ describe('WebsocketLoadBalancer', function () {
|
||||
],
|
||||
}
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(true)
|
||||
})
|
||||
it('should return false if the user id does not match', function () {
|
||||
it('should return false if the user id does not match', function (ctx) {
|
||||
const message = {
|
||||
message: messageName,
|
||||
payload: [
|
||||
@@ -97,7 +110,7 @@ describe('WebsocketLoadBalancer', function () {
|
||||
],
|
||||
}
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -107,23 +120,23 @@ describe('WebsocketLoadBalancer', function () {
|
||||
const client = {
|
||||
ol_context: { user_id: 'abcd' },
|
||||
}
|
||||
it('should return false, when the user_id does not match', function () {
|
||||
it('should return false, when the user_id does not match', function (ctx) {
|
||||
const message = {
|
||||
message: messageName,
|
||||
payload: ['xyz'],
|
||||
}
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(false)
|
||||
})
|
||||
|
||||
it('should return true, if the user_id matches', function () {
|
||||
it('should return true, if the user_id matches', function (ctx) {
|
||||
const message = {
|
||||
message: messageName,
|
||||
payload: [`${client.ol_context.user_id}`],
|
||||
}
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(true)
|
||||
})
|
||||
})
|
||||
@@ -143,9 +156,9 @@ describe('WebsocketLoadBalancer', function () {
|
||||
},
|
||||
}
|
||||
|
||||
it('should return false', function () {
|
||||
it('should return false', function (ctx) {
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -157,9 +170,9 @@ describe('WebsocketLoadBalancer', function () {
|
||||
},
|
||||
}
|
||||
|
||||
it('should return true', function () {
|
||||
it('should return true', function (ctx) {
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(true)
|
||||
})
|
||||
})
|
||||
@@ -178,9 +191,9 @@ describe('WebsocketLoadBalancer', function () {
|
||||
},
|
||||
}
|
||||
|
||||
it('should return false', function () {
|
||||
it('should return false', function (ctx) {
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -192,9 +205,9 @@ describe('WebsocketLoadBalancer', function () {
|
||||
},
|
||||
}
|
||||
|
||||
it('should return false', function () {
|
||||
it('should return false', function (ctx) {
|
||||
expect(
|
||||
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
|
||||
).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -203,24 +216,24 @@ describe('WebsocketLoadBalancer', function () {
|
||||
})
|
||||
|
||||
describe('emitToRoom', function () {
|
||||
beforeEach(function () {
|
||||
return this.WebsocketLoadBalancer.emitToRoom(
|
||||
this.room_id,
|
||||
this.message,
|
||||
...Array.from(this.payload)
|
||||
beforeEach(function (ctx) {
|
||||
ctx.WebsocketLoadBalancer.emitToRoom(
|
||||
ctx.room_id,
|
||||
ctx.message,
|
||||
...Array.from(ctx.payload)
|
||||
)
|
||||
})
|
||||
|
||||
return it('should publish the message to redis', function () {
|
||||
return this.ChannelManager.publish
|
||||
it('should publish the message to redis', function (ctx) {
|
||||
ctx.ChannelManager.publish
|
||||
.calledWith(
|
||||
this.WebsocketLoadBalancer.rclientPubList[0],
|
||||
ctx.WebsocketLoadBalancer.rclientPubList[0],
|
||||
'editor-events',
|
||||
this.room_id,
|
||||
ctx.room_id,
|
||||
JSON.stringify({
|
||||
room_id: this.room_id,
|
||||
message: this.message,
|
||||
payload: this.payload,
|
||||
room_id: ctx.room_id,
|
||||
message: ctx.message,
|
||||
payload: ctx.payload,
|
||||
})
|
||||
)
|
||||
.should.equal(true)
|
||||
@@ -228,224 +241,224 @@ describe('WebsocketLoadBalancer', function () {
|
||||
})
|
||||
|
||||
describe('emitToAll', function () {
|
||||
beforeEach(function () {
|
||||
this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
|
||||
return this.WebsocketLoadBalancer.emitToAll(
|
||||
this.message,
|
||||
...Array.from(this.payload)
|
||||
beforeEach(function (ctx) {
|
||||
ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
|
||||
ctx.WebsocketLoadBalancer.emitToAll(
|
||||
ctx.message,
|
||||
...Array.from(ctx.payload)
|
||||
)
|
||||
})
|
||||
|
||||
return it("should emit to the room 'all'", function () {
|
||||
return this.WebsocketLoadBalancer.emitToRoom
|
||||
.calledWith('all', this.message, ...Array.from(this.payload))
|
||||
it("should emit to the room 'all'", function (ctx) {
|
||||
ctx.WebsocketLoadBalancer.emitToRoom
|
||||
.calledWith('all', ctx.message, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listenForEditorEvents', function () {
|
||||
beforeEach(function () {
|
||||
this.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
|
||||
return this.WebsocketLoadBalancer.listenForEditorEvents()
|
||||
beforeEach(function (ctx) {
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
|
||||
ctx.WebsocketLoadBalancer.listenForEditorEvents()
|
||||
})
|
||||
|
||||
it('should subscribe to the editor-events channel', function () {
|
||||
return this.WebsocketLoadBalancer.rclientSubList[0].subscribe
|
||||
it('should subscribe to the editor-events channel', function (ctx) {
|
||||
ctx.WebsocketLoadBalancer.rclientSubList[0].subscribe
|
||||
.calledWith('editor-events')
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should process the events with _processEditorEvent', function () {
|
||||
return this.WebsocketLoadBalancer.rclientSubList[0].on
|
||||
it('should process the events with _processEditorEvent', function (ctx) {
|
||||
ctx.WebsocketLoadBalancer.rclientSubList[0].on
|
||||
.calledWith('message', sinon.match.func)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('_processEditorEvent', function () {
|
||||
describe('_processEditorEvent', function () {
|
||||
describe('with bad JSON', function () {
|
||||
beforeEach(function () {
|
||||
this.isRestrictedUser = false
|
||||
this.SafeJsonParse.parse = sinon
|
||||
beforeEach(function (ctx) {
|
||||
ctx.isRestrictedUser = false
|
||||
ctx.SafeJsonParse.parse = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, new Error('oops'))
|
||||
return this.WebsocketLoadBalancer._processEditorEvent(
|
||||
this.io,
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent(
|
||||
ctx.io,
|
||||
'editor-events',
|
||||
'blah'
|
||||
)
|
||||
})
|
||||
|
||||
return it('should log an error', function () {
|
||||
return this.logger.error.called.should.equal(true)
|
||||
it('should log an error', function (ctx) {
|
||||
ctx.logger.error.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a designated room', function () {
|
||||
beforeEach(function () {
|
||||
this.io.sockets = {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.io.sockets = {
|
||||
clients: sinon.stub().returns([
|
||||
{
|
||||
id: 'client-id-1',
|
||||
emit: (this.emit1 = sinon.stub()),
|
||||
emit: (ctx.emit1 = sinon.stub()),
|
||||
ol_context: {},
|
||||
},
|
||||
{
|
||||
id: 'client-id-2',
|
||||
emit: (this.emit2 = sinon.stub()),
|
||||
emit: (ctx.emit2 = sinon.stub()),
|
||||
ol_context: {},
|
||||
},
|
||||
{
|
||||
id: 'client-id-1',
|
||||
emit: (this.emit3 = sinon.stub()),
|
||||
emit: (ctx.emit3 = sinon.stub()),
|
||||
ol_context: {},
|
||||
}, // duplicate client
|
||||
]),
|
||||
}
|
||||
const data = JSON.stringify({
|
||||
room_id: this.room_id,
|
||||
message: this.message,
|
||||
payload: this.payload,
|
||||
room_id: ctx.room_id,
|
||||
message: ctx.message,
|
||||
payload: ctx.payload,
|
||||
})
|
||||
return this.WebsocketLoadBalancer._processEditorEvent(
|
||||
this.io,
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent(
|
||||
ctx.io,
|
||||
'editor-events',
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
return it('should send the message to all (unique) clients in the room', function () {
|
||||
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
|
||||
this.emit1
|
||||
.calledWith(this.message, ...Array.from(this.payload))
|
||||
it('should send the message to all (unique) clients in the room', function (ctx) {
|
||||
ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
|
||||
ctx.emit1
|
||||
.calledWith(ctx.message, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
this.emit2
|
||||
.calledWith(this.message, ...Array.from(this.payload))
|
||||
ctx.emit2
|
||||
.calledWith(ctx.message, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
return this.emit3.called.should.equal(false)
|
||||
ctx.emit3.called.should.equal(false)
|
||||
})
|
||||
}) // duplicate client should be ignored
|
||||
|
||||
describe('with a designated room, and restricted clients, not restricted message', function () {
|
||||
beforeEach(function () {
|
||||
this.io.sockets = {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.io.sockets = {
|
||||
clients: sinon.stub().returns([
|
||||
{
|
||||
id: 'client-id-1',
|
||||
emit: (this.emit1 = sinon.stub()),
|
||||
emit: (ctx.emit1 = sinon.stub()),
|
||||
ol_context: {},
|
||||
},
|
||||
{
|
||||
id: 'client-id-2',
|
||||
emit: (this.emit2 = sinon.stub()),
|
||||
emit: (ctx.emit2 = sinon.stub()),
|
||||
ol_context: {},
|
||||
},
|
||||
{
|
||||
id: 'client-id-1',
|
||||
emit: (this.emit3 = sinon.stub()),
|
||||
emit: (ctx.emit3 = sinon.stub()),
|
||||
ol_context: {},
|
||||
}, // duplicate client
|
||||
{
|
||||
id: 'client-id-4',
|
||||
emit: (this.emit4 = sinon.stub()),
|
||||
emit: (ctx.emit4 = sinon.stub()),
|
||||
ol_context: { is_restricted_user: true },
|
||||
},
|
||||
]),
|
||||
}
|
||||
const data = JSON.stringify({
|
||||
room_id: this.room_id,
|
||||
message: this.message,
|
||||
payload: this.payload,
|
||||
room_id: ctx.room_id,
|
||||
message: ctx.message,
|
||||
payload: ctx.payload,
|
||||
})
|
||||
return this.WebsocketLoadBalancer._processEditorEvent(
|
||||
this.io,
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent(
|
||||
ctx.io,
|
||||
'editor-events',
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
return it('should send the message to all (unique) clients in the room', function () {
|
||||
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
|
||||
this.emit1
|
||||
.calledWith(this.message, ...Array.from(this.payload))
|
||||
it('should send the message to all (unique) clients in the room', function (ctx) {
|
||||
ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
|
||||
ctx.emit1
|
||||
.calledWith(ctx.message, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
this.emit2
|
||||
.calledWith(this.message, ...Array.from(this.payload))
|
||||
ctx.emit2
|
||||
.calledWith(ctx.message, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
this.emit3.called.should.equal(false) // duplicate client should be ignored
|
||||
return this.emit4.called.should.equal(true)
|
||||
ctx.emit3.called.should.equal(false) // duplicate client should be ignored
|
||||
ctx.emit4.called.should.equal(true)
|
||||
})
|
||||
}) // restricted client, but should be called
|
||||
|
||||
describe('with a designated room, and restricted clients, restricted message', function () {
|
||||
beforeEach(function () {
|
||||
this.io.sockets = {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.io.sockets = {
|
||||
clients: sinon.stub().returns([
|
||||
{
|
||||
id: 'client-id-1',
|
||||
emit: (this.emit1 = sinon.stub()),
|
||||
emit: (ctx.emit1 = sinon.stub()),
|
||||
ol_context: {},
|
||||
},
|
||||
{
|
||||
id: 'client-id-2',
|
||||
emit: (this.emit2 = sinon.stub()),
|
||||
emit: (ctx.emit2 = sinon.stub()),
|
||||
ol_context: {},
|
||||
},
|
||||
{
|
||||
id: 'client-id-1',
|
||||
emit: (this.emit3 = sinon.stub()),
|
||||
emit: (ctx.emit3 = sinon.stub()),
|
||||
ol_context: {},
|
||||
}, // duplicate client
|
||||
{
|
||||
id: 'client-id-4',
|
||||
emit: (this.emit4 = sinon.stub()),
|
||||
emit: (ctx.emit4 = sinon.stub()),
|
||||
ol_context: { is_restricted_user: true },
|
||||
},
|
||||
]),
|
||||
}
|
||||
const data = JSON.stringify({
|
||||
room_id: this.room_id,
|
||||
message: (this.restrictedMessage = 'new-comment'),
|
||||
payload: this.payload,
|
||||
room_id: ctx.room_id,
|
||||
message: (ctx.restrictedMessage = 'new-comment'),
|
||||
payload: ctx.payload,
|
||||
})
|
||||
return this.WebsocketLoadBalancer._processEditorEvent(
|
||||
this.io,
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent(
|
||||
ctx.io,
|
||||
'editor-events',
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
return it('should send the message to all (unique) clients in the room, who are not restricted', function () {
|
||||
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
|
||||
this.emit1
|
||||
.calledWith(this.restrictedMessage, ...Array.from(this.payload))
|
||||
it('should send the message to all (unique) clients in the room, who are not restricted', function (ctx) {
|
||||
ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
|
||||
ctx.emit1
|
||||
.calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
this.emit2
|
||||
.calledWith(this.restrictedMessage, ...Array.from(this.payload))
|
||||
ctx.emit2
|
||||
.calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
this.emit3.called.should.equal(false) // duplicate client should be ignored
|
||||
return this.emit4.called.should.equal(false)
|
||||
ctx.emit3.called.should.equal(false) // duplicate client should be ignored
|
||||
ctx.emit4.called.should.equal(false)
|
||||
})
|
||||
}) // restricted client, should not be called
|
||||
|
||||
describe('when emitting to all', function () {
|
||||
beforeEach(function () {
|
||||
this.io.sockets = { emit: (this.emit = sinon.stub()) }
|
||||
beforeEach(function (ctx) {
|
||||
ctx.io.sockets = { emit: (ctx.emit = sinon.stub()) }
|
||||
const data = JSON.stringify({
|
||||
room_id: 'all',
|
||||
message: this.message,
|
||||
payload: this.payload,
|
||||
message: ctx.message,
|
||||
payload: ctx.payload,
|
||||
})
|
||||
return this.WebsocketLoadBalancer._processEditorEvent(
|
||||
this.io,
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent(
|
||||
ctx.io,
|
||||
'editor-events',
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
return it('should send the message to all clients', function () {
|
||||
return this.emit
|
||||
.calledWith(this.message, ...Array.from(this.payload))
|
||||
it('should send the message to all clients', function (ctx) {
|
||||
ctx.emit
|
||||
.calledWith(ctx.message, ...Array.from(ctx.payload))
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
@@ -474,24 +487,24 @@ describe('WebsocketLoadBalancer', function () {
|
||||
disconnect: sinon.stub(),
|
||||
},
|
||||
]
|
||||
beforeEach(function () {
|
||||
this.io.sockets = {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.io.sockets = {
|
||||
clients: sinon.stub().returns(clients),
|
||||
}
|
||||
const data = JSON.stringify({
|
||||
room_id: this.room_id,
|
||||
room_id: ctx.room_id,
|
||||
message,
|
||||
payload,
|
||||
})
|
||||
return this.WebsocketLoadBalancer._processEditorEvent(
|
||||
this.io,
|
||||
ctx.WebsocketLoadBalancer._processEditorEvent(
|
||||
ctx.io,
|
||||
'editor-events',
|
||||
data
|
||||
)
|
||||
})
|
||||
|
||||
it('should disconnect the matching client, while sending message to other clients', function () {
|
||||
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
|
||||
it('should disconnect the matching client, while sending message to other clients', function (ctx) {
|
||||
ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
|
||||
|
||||
const [client1, client2, client3] = clients
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
import sinon from 'sinon'
|
||||
let MockClient
|
||||
const sinon = require('sinon')
|
||||
|
||||
let idCounter = 0
|
||||
|
||||
module.exports = MockClient = class MockClient {
|
||||
export default MockClient = class MockClient {
|
||||
constructor() {
|
||||
this.ol_context = {}
|
||||
this.join = sinon.stub()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, beforeEach, chai, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import chaiAsPromised from 'chai-as-promised'
|
||||
import sinonChai from 'sinon-chai'
|
||||
|
||||
// Chai configuration
|
||||
chai.should()
|
||||
chai.use(chaiAsPromised)
|
||||
chai.use(sinonChai)
|
||||
|
||||
// Global stubs
|
||||
const sandbox = sinon.createSandbox()
|
||||
const stubs = {
|
||||
logger: {
|
||||
debug: sandbox.stub(),
|
||||
log: sandbox.stub(),
|
||||
info: sandbox.stub(),
|
||||
warn: sandbox.stub(),
|
||||
err: sandbox.stub(),
|
||||
error: sandbox.stub(),
|
||||
},
|
||||
}
|
||||
|
||||
// Mocha hooks
|
||||
beforeEach(ctx => {
|
||||
ctx.logger = stubs.logger
|
||||
vi.doMock('@overleaf/logger', () => ({ default: ctx.logger }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.reset()
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
"config/**/*",
|
||||
"scripts/**/*",
|
||||
"test/**/*",
|
||||
"types"
|
||||
"types",
|
||||
"vitest.config.unit.cjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const { defineConfig } = require('vitest/config')
|
||||
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'junit',
|
||||
{
|
||||
classnameTemplate: `Unit tests.{filename}`,
|
||||
},
|
||||
],
|
||||
],
|
||||
outputFile: 'reports/junit-vitest-unit.xml',
|
||||
}
|
||||
}
|
||||
module.exports = defineConfig({
|
||||
test: {
|
||||
include: ['test/unit/js/**/*.test.{js,ts}'],
|
||||
setupFiles: ['./test/unit/setup.js'],
|
||||
isolate: false,
|
||||
...reporterOptions,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user