diff --git a/services/real-time/.dockerignore b/services/real-time/.dockerignore index 386f26df30..ba1c3442de 100644 --- a/services/real-time/.dockerignore +++ b/services/real-time/.dockerignore @@ -5,5 +5,3 @@ gitrev .npm .nvmrc nodemon.json -app.js -**/js/* diff --git a/services/real-time/.eslintrc b/services/real-time/.eslintrc new file mode 100644 index 0000000000..76dad1561d --- /dev/null +++ b/services/real-time/.eslintrc @@ -0,0 +1,64 @@ +// this file was auto-generated, do not edit it directly. +// instead run bin/update_build_scripts from +// https://github.com/sharelatex/sharelatex-dev-environment +{ + "extends": [ + "standard", + "prettier", + "prettier/standard" + ], + "parserOptions": { + "ecmaVersion": 2018 + }, + "plugins": [ + "mocha", + "chai-expect", + "chai-friendly" + ], + "env": { + "node": true, + "mocha": true + }, + "rules": { + // Swap the no-unused-expressions rule with a more chai-friendly one + "no-unused-expressions": 0, + "chai-friendly/no-unused-expressions": "error" + }, + "overrides": [ + { + // Test specific rules + "files": ["test/**/*.js"], + "globals": { + "expect": true + }, + "rules": { + // mocha-specific rules + "mocha/handle-done-callback": "error", + "mocha/no-exclusive-tests": "error", + "mocha/no-global-tests": "error", + "mocha/no-identical-title": "error", + "mocha/no-nested-tests": "error", + "mocha/no-pending-tests": "error", + "mocha/no-skipped-tests": "error", + "mocha/no-mocha-arrows": "error", + + // chai-specific rules + "chai-expect/missing-assertion": "error", + "chai-expect/terminating-properties": "error", + + // prefer-arrow-callback applies to all callbacks, not just ones in mocha tests. + // we don't enforce this at the top-level - just in tests to manage `this` scope + // based on mocha's context mechanism + "mocha/prefer-arrow-callback": "error" + } + }, + { + // Backend specific rules + "files": ["app/**/*.js", "app.js", "index.js"], + "rules": { + // don't allow console.log in backend code + "no-console": "error" + } + } + ] +} diff --git a/services/real-time/.gitignore b/services/real-time/.gitignore index ff0c7e15d2..50678c09e9 100644 --- a/services/real-time/.gitignore +++ b/services/real-time/.gitignore @@ -1,7 +1,2 @@ node_modules forever -app.js -app/js -test/unit/js -test/acceptance/js -**/*.map diff --git a/services/real-time/.prettierrc b/services/real-time/.prettierrc new file mode 100644 index 0000000000..24f9ec526f --- /dev/null +++ b/services/real-time/.prettierrc @@ -0,0 +1,7 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment +{ + "semi": false, + "singleQuote": true +} diff --git a/services/real-time/Dockerfile b/services/real-time/Dockerfile index 71e74fe251..b07f7117bc 100644 --- a/services/real-time/Dockerfile +++ b/services/real-time/Dockerfile @@ -1,7 +1,6 @@ # This file was auto-generated, do not edit it directly. # Instead run bin/update_build_scripts from # https://github.com/sharelatex/sharelatex-dev-environment -# Version: 1.3.5 FROM node:10.21.0 as base @@ -12,12 +11,11 @@ FROM base as app #wildcard as some files may not be in all repos COPY package*.json npm-shrink*.json /app/ -RUN npm install --quiet +RUN npm ci --quiet COPY . /app -RUN npm run compile:all FROM base diff --git a/services/real-time/Jenkinsfile b/services/real-time/Jenkinsfile index 684fb7daca..4fc4f79e8a 100644 --- a/services/real-time/Jenkinsfile +++ b/services/real-time/Jenkinsfile @@ -37,6 +37,13 @@ pipeline { } } + stage('Linting') { + steps { + sh 'DOCKER_COMPOSE_FLAGS="-f docker-compose.ci.yml" make format' + sh 'DOCKER_COMPOSE_FLAGS="-f docker-compose.ci.yml" make lint' + } + } + stage('Unit Tests') { steps { sh 'DOCKER_COMPOSE_FLAGS="-f docker-compose.ci.yml" make test_unit' diff --git a/services/real-time/Makefile b/services/real-time/Makefile index d8b68a699f..437700ee2f 100644 --- a/services/real-time/Makefile +++ b/services/real-time/Makefile @@ -1,11 +1,12 @@ # This file was auto-generated, do not edit it directly. # Instead run bin/update_build_scripts from # https://github.com/sharelatex/sharelatex-dev-environment -# Version: 1.3.5 BUILD_NUMBER ?= local BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) PROJECT_NAME = real-time +BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') + DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ BRANCH_NAME=$(BRANCH_NAME) \ @@ -13,34 +14,63 @@ DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ MOCHA_GREP=${MOCHA_GREP} \ docker-compose ${DOCKER_COMPOSE_FLAGS} +DOCKER_COMPOSE_TEST_ACCEPTANCE = \ + COMPOSE_PROJECT_NAME=test_acceptance_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +DOCKER_COMPOSE_TEST_UNIT = \ + COMPOSE_PROJECT_NAME=test_unit_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + clean: docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) docker rmi gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) - rm -f app.js - rm -rf app/js - rm -rf test/unit/js - rm -rf test/acceptance/js -test: test_unit test_acceptance +format: + $(DOCKER_COMPOSE) run --rm test_unit npm run format + +format_fix: + $(DOCKER_COMPOSE) run --rm test_unit npm run format:fix + +lint: + $(DOCKER_COMPOSE) run --rm test_unit npm run lint + +test: format lint test_unit test_acceptance test_unit: - @[ ! -d test/unit ] && echo "real-time has no unit tests" || $(DOCKER_COMPOSE) run --rm test_unit +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit + $(MAKE) test_unit_clean +endif -test_acceptance: test_clean test_acceptance_pre_run test_acceptance_run +test_clean: test_unit_clean +test_unit_clean: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 +endif -test_acceptance_debug: test_clean test_acceptance_pre_run test_acceptance_run_debug +test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run + $(MAKE) test_acceptance_clean + +test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug + $(MAKE) test_acceptance_clean test_acceptance_run: - @[ ! -d test/acceptance ] && echo "real-time has no acceptance tests" || $(DOCKER_COMPOSE) run --rm test_acceptance +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance +endif test_acceptance_run_debug: - @[ ! -d test/acceptance ] && echo "real-time has no acceptance tests" || $(DOCKER_COMPOSE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +endif -test_clean: - $(DOCKER_COMPOSE) down -v -t 0 +test_clean: test_acceptance_clean +test_acceptance_clean: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 test_acceptance_pre_run: - @[ ! -f test/acceptance/js/scripts/pre-run ] && echo "real-time has no pre acceptance tests task" || $(DOCKER_COMPOSE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +endif build: docker build --pull --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ @@ -54,8 +84,5 @@ publish: docker push $(DOCKER_REPO)/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) -lint: - -format: .PHONY: clean test test_unit test_acceptance test_clean build publish diff --git a/services/real-time/app.coffee b/services/real-time/app.coffee deleted file mode 100644 index 9f4c9cc44f..0000000000 --- a/services/real-time/app.coffee +++ /dev/null @@ -1,182 +0,0 @@ -Metrics = require("metrics-sharelatex") -Settings = require "settings-sharelatex" -Metrics.initialize(Settings.appName or "real-time") -async = require("async") -_ = require "underscore" - -logger = require "logger-sharelatex" -logger.initialize("real-time") -Metrics.event_loop.monitor(logger) - -express = require("express") -session = require("express-session") -redis = require("redis-sharelatex") -if Settings.sentry?.dsn? - logger.initializeErrorReporting(Settings.sentry.dsn) - -sessionRedisClient = redis.createClient(Settings.redis.websessions) - -RedisStore = require('connect-redis')(session) -SessionSockets = require('./app/js/SessionSockets') -CookieParser = require("cookie-parser") - -DrainManager = require("./app/js/DrainManager") -HealthCheckManager = require("./app/js/HealthCheckManager") - -# work around frame handler bug in socket.io v0.9.16 -require("./socket.io.patch.js") -# Set up socket.io server -app = express() - -server = require('http').createServer(app) -io = require('socket.io').listen(server) - -# Bind to sessions -sessionStore = new RedisStore(client: sessionRedisClient) -cookieParser = CookieParser(Settings.security.sessionSecret) - -sessionSockets = new SessionSockets(io, sessionStore, cookieParser, Settings.cookieName) - -Metrics.injectMetricsRoute(app) -app.use(Metrics.http.monitor(logger)) - -io.configure -> - io.enable('browser client minification') - io.enable('browser client etag') - - # Fix for Safari 5 error of "Error during WebSocket handshake: location mismatch" - # See http://answers.dotcloud.com/question/578/problem-with-websocket-over-ssl-in-safari-with - io.set('match origin protocol', true) - - # gzip uses a Node 0.8.x method of calling the gzip program which - # doesn't work with 0.6.x - #io.enable('browser client gzip') - io.set('transports', ['websocket', 'flashsocket', 'htmlfile', 'xhr-polling', 'jsonp-polling']) - io.set('log level', 1) - -app.get "/", (req, res, next) -> - res.send "real-time-sharelatex is alive" - -app.get "/status", (req, res, next) -> - if Settings.shutDownInProgress - res.send 503 # Service unavailable - else - res.send "real-time-sharelatex is alive" - -app.get "/debug/events", (req, res, next) -> - Settings.debugEvents = parseInt(req.query?.count,10) || 20 - logger.log {count: Settings.debugEvents}, "starting debug mode" - res.send "debug mode will log next #{Settings.debugEvents} events" - -rclient = require("redis-sharelatex").createClient(Settings.redis.realtime) - -healthCheck = (req, res, next)-> - rclient.healthCheck (error) -> - if error? - logger.err {err: error}, "failed redis health check" - res.sendStatus 500 - else if HealthCheckManager.isFailing() - status = HealthCheckManager.status() - logger.err {pubSubErrors: status}, "failed pubsub health check" - res.sendStatus 500 - else - res.sendStatus 200 - -app.get "/health_check", healthCheck - -app.get "/health_check/redis", healthCheck - - - -Router = require "./app/js/Router" -Router.configure(app, io, sessionSockets) - -WebsocketLoadBalancer = require "./app/js/WebsocketLoadBalancer" -WebsocketLoadBalancer.listenForEditorEvents(io) - -DocumentUpdaterController = require "./app/js/DocumentUpdaterController" -DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io) - -port = Settings.internal.realTime.port -host = Settings.internal.realTime.host - -server.listen port, host, (error) -> - throw error if error? - logger.info "realtime starting up, listening on #{host}:#{port}" - -# Stop huge stack traces in logs from all the socket.io parsing steps. -Error.stackTraceLimit = 10 - - -shutdownCleanly = (signal) -> - connectedClients = io.sockets.clients()?.length - if connectedClients == 0 - logger.warn("no clients connected, exiting") - process.exit() - else - logger.warn {connectedClients}, "clients still connected, not shutting down yet" - setTimeout () -> - shutdownCleanly(signal) - , 30 * 1000 - -drainAndShutdown = (signal) -> - if Settings.shutDownInProgress - logger.warn signal: signal, "shutdown already in progress, ignoring signal" - return - else - Settings.shutDownInProgress = true - statusCheckInterval = Settings.statusCheckInterval - if statusCheckInterval - logger.warn signal: signal, "received interrupt, delay drain by #{statusCheckInterval}ms" - setTimeout () -> - logger.warn signal: signal, "received interrupt, starting drain over #{shutdownDrainTimeWindow} mins" - DrainManager.startDrainTimeWindow(io, shutdownDrainTimeWindow) - shutdownCleanly(signal) - , statusCheckInterval - - -Settings.shutDownInProgress = false -if Settings.shutdownDrainTimeWindow? - shutdownDrainTimeWindow = parseInt(Settings.shutdownDrainTimeWindow, 10) - logger.log shutdownDrainTimeWindow: shutdownDrainTimeWindow,"shutdownDrainTimeWindow enabled" - for signal in ['SIGINT', 'SIGHUP', 'SIGQUIT', 'SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGABRT'] - process.on signal, drainAndShutdown # signal is passed as argument to event handler - - # global exception handler - if Settings.errors?.catchUncaughtErrors - process.removeAllListeners('uncaughtException') - process.on 'uncaughtException', (error) -> - if ['EPIPE', 'ECONNRESET'].includes(error.code) - Metrics.inc('disconnected_write', 1, {status: error.code}) - return logger.warn err: error, 'attempted to write to disconnected client' - logger.error err: error, 'uncaught exception' - if Settings.errors?.shutdownOnUncaughtError - drainAndShutdown('SIGABRT') - -if Settings.continualPubsubTraffic - console.log "continualPubsubTraffic enabled" - - pubsubClient = redis.createClient(Settings.redis.pubsub) - clusterClient = redis.createClient(Settings.redis.websessions) - - publishJob = (channel, callback)-> - checker = new HealthCheckManager(channel) - logger.debug {channel:channel}, "sending pub to keep connection alive" - json = JSON.stringify({health_check:true, key: checker.id, date: new Date().toString()}) - Metrics.summary "redis.publish.#{channel}", json.length - pubsubClient.publish channel, json, (err)-> - if err? - logger.err {err, channel}, "error publishing pubsub traffic to redis" - blob = JSON.stringify({keep: "alive"}) - Metrics.summary "redis.publish.cluster-continual-traffic", blob.length - clusterClient.publish "cluster-continual-traffic", blob, callback - - - runPubSubTraffic = -> - async.map ["applied-ops", "editor-events"], publishJob, (err)-> - setTimeout(runPubSubTraffic, 1000 * 20) - - runPubSubTraffic() - - - diff --git a/services/real-time/app.js b/services/real-time/app.js new file mode 100644 index 0000000000..b34b33df61 --- /dev/null +++ b/services/real-time/app.js @@ -0,0 +1,252 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS103: Rewrite code to no longer use __guard__ + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const Metrics = require('metrics-sharelatex') +const Settings = require('settings-sharelatex') +Metrics.initialize(Settings.appName || 'real-time') +const async = require('async') + +const logger = require('logger-sharelatex') +logger.initialize('real-time') +Metrics.event_loop.monitor(logger) + +const express = require('express') +const session = require('express-session') +const redis = require('redis-sharelatex') +if ((Settings.sentry != null ? Settings.sentry.dsn : undefined) != null) { + logger.initializeErrorReporting(Settings.sentry.dsn) +} + +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') + +// work around frame handler bug in socket.io v0.9.16 +require('./socket.io.patch.js') +// Set up socket.io server +const app = express() + +const server = require('http').createServer(app) +const io = require('socket.io').listen(server) + +// Bind to sessions +const sessionStore = new RedisStore({ client: sessionRedisClient }) +const cookieParser = CookieParser(Settings.security.sessionSecret) + +const sessionSockets = new SessionSockets( + io, + sessionStore, + cookieParser, + Settings.cookieName +) + +Metrics.injectMetricsRoute(app) +app.use(Metrics.http.monitor(logger)) + +io.configure(function () { + io.enable('browser client minification') + io.enable('browser client etag') + + // Fix for Safari 5 error of "Error during WebSocket handshake: location mismatch" + // See http://answers.dotcloud.com/question/578/problem-with-websocket-over-ssl-in-safari-with + io.set('match origin protocol', true) + + // gzip uses a Node 0.8.x method of calling the gzip program which + // doesn't work with 0.6.x + // io.enable('browser client gzip') + io.set('transports', [ + 'websocket', + 'flashsocket', + 'htmlfile', + 'xhr-polling', + 'jsonp-polling' + ]) + return io.set('log level', 1) +}) + +app.get('/', (req, res, next) => res.send('real-time-sharelatex is alive')) + +app.get('/status', function (req, res, next) { + if (Settings.shutDownInProgress) { + return res.send(503) // Service unavailable + } else { + return res.send('real-time-sharelatex is alive') + } +}) + +app.get('/debug/events', function (req, res, next) { + Settings.debugEvents = + parseInt(req.query != null ? req.query.count : undefined, 10) || 20 + logger.log({ count: Settings.debugEvents }, 'starting debug mode') + return res.send(`debug mode will log next ${Settings.debugEvents} events`) +}) + +const rclient = require('redis-sharelatex').createClient( + Settings.redis.realtime +) + +const healthCheck = (req, res, next) => + rclient.healthCheck(function (error) { + if (error != null) { + logger.err({ err: error }, 'failed redis health check') + return res.sendStatus(500) + } else if (HealthCheckManager.isFailing()) { + const status = HealthCheckManager.status() + logger.err({ pubSubErrors: status }, 'failed pubsub health check') + return res.sendStatus(500) + } else { + return res.sendStatus(200) + } + }) + +app.get('/health_check', healthCheck) + +app.get('/health_check/redis', healthCheck) + +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 +const { host } = Settings.internal.realTime + +server.listen(port, host, function (error) { + if (error != null) { + throw error + } + return logger.info(`realtime starting up, listening on ${host}:${port}`) +}) + +// Stop huge stack traces in logs from all the socket.io parsing steps. +Error.stackTraceLimit = 10 + +var shutdownCleanly = function (signal) { + const connectedClients = __guard__(io.sockets.clients(), (x) => x.length) + if (connectedClients === 0) { + logger.warn('no clients connected, exiting') + return process.exit() + } else { + logger.warn( + { connectedClients }, + 'clients still connected, not shutting down yet' + ) + return setTimeout(() => shutdownCleanly(signal), 30 * 1000) + } +} + +const drainAndShutdown = function (signal) { + if (Settings.shutDownInProgress) { + logger.warn({ signal }, 'shutdown already in progress, ignoring signal') + } else { + Settings.shutDownInProgress = true + const { statusCheckInterval } = Settings + if (statusCheckInterval) { + logger.warn( + { signal }, + `received interrupt, delay drain by ${statusCheckInterval}ms` + ) + } + return setTimeout(function () { + logger.warn( + { signal }, + `received interrupt, starting drain over ${shutdownDrainTimeWindow} mins` + ) + DrainManager.startDrainTimeWindow(io, shutdownDrainTimeWindow) + return shutdownCleanly(signal) + }, statusCheckInterval) + } +} + +Settings.shutDownInProgress = false +if (Settings.shutdownDrainTimeWindow != null) { + var shutdownDrainTimeWindow = parseInt(Settings.shutdownDrainTimeWindow, 10) + logger.log({ shutdownDrainTimeWindow }, 'shutdownDrainTimeWindow enabled') + for (const signal of [ + 'SIGINT', + 'SIGHUP', + 'SIGQUIT', + 'SIGUSR1', + 'SIGUSR2', + 'SIGTERM', + 'SIGABRT' + ]) { + process.on(signal, drainAndShutdown) + } // signal is passed as argument to event handler + + // global exception handler + if ( + Settings.errors != null ? Settings.errors.catchUncaughtErrors : undefined + ) { + process.removeAllListeners('uncaughtException') + process.on('uncaughtException', function (error) { + if (['EPIPE', 'ECONNRESET'].includes(error.code)) { + Metrics.inc('disconnected_write', 1, { status: error.code }) + return logger.warn( + { err: error }, + 'attempted to write to disconnected client' + ) + } + logger.error({ err: error }, 'uncaught exception') + if ( + Settings.errors != null + ? Settings.errors.shutdownOnUncaughtError + : undefined + ) { + return drainAndShutdown('SIGABRT') + } + }) + } +} + +if (Settings.continualPubsubTraffic) { + logger.warn('continualPubsubTraffic enabled') + + const pubsubClient = redis.createClient(Settings.redis.pubsub) + const clusterClient = redis.createClient(Settings.redis.websessions) + + const publishJob = function (channel, callback) { + const checker = new HealthCheckManager(channel) + logger.debug({ channel }, 'sending pub to keep connection alive') + const json = JSON.stringify({ + health_check: true, + key: checker.id, + date: new Date().toString() + }) + Metrics.summary(`redis.publish.${channel}`, json.length) + return pubsubClient.publish(channel, json, function (err) { + if (err != null) { + logger.err({ err, channel }, 'error publishing pubsub traffic to redis') + } + const blob = JSON.stringify({ keep: 'alive' }) + Metrics.summary('redis.publish.cluster-continual-traffic', blob.length) + return clusterClient.publish('cluster-continual-traffic', blob, callback) + }) + } + + var runPubSubTraffic = () => + async.map(['applied-ops', 'editor-events'], publishJob, () => + setTimeout(runPubSubTraffic, 1000 * 20) + ) + + runPubSubTraffic() +} + +function __guard__(value, transform) { + return typeof value !== 'undefined' && value !== null + ? transform(value) + : undefined +} diff --git a/services/real-time/app/coffee/AuthorizationManager.coffee b/services/real-time/app/coffee/AuthorizationManager.coffee deleted file mode 100644 index 50d76537ce..0000000000 --- a/services/real-time/app/coffee/AuthorizationManager.coffee +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = AuthorizationManager = - assertClientCanViewProject: (client, callback = (error) ->) -> - AuthorizationManager._assertClientHasPrivilegeLevel client, ["readOnly", "readAndWrite", "owner"], callback - - assertClientCanEditProject: (client, callback = (error) ->) -> - AuthorizationManager._assertClientHasPrivilegeLevel client, ["readAndWrite", "owner"], callback - - _assertClientHasPrivilegeLevel: (client, allowedLevels, callback = (error) ->) -> - if client.ol_context["privilege_level"] in allowedLevels - callback null - else - callback new Error("not authorized") - - assertClientCanViewProjectAndDoc: (client, doc_id, callback = (error) ->) -> - AuthorizationManager.assertClientCanViewProject client, (error) -> - return callback(error) if error? - AuthorizationManager._assertClientCanAccessDoc client, doc_id, callback - - assertClientCanEditProjectAndDoc: (client, doc_id, callback = (error) ->) -> - AuthorizationManager.assertClientCanEditProject client, (error) -> - return callback(error) if error? - AuthorizationManager._assertClientCanAccessDoc client, doc_id, callback - - _assertClientCanAccessDoc: (client, doc_id, callback = (error) ->) -> - if client.ol_context["doc:#{doc_id}"] is "allowed" - callback null - else - callback new Error("not authorized") - - addAccessToDoc: (client, doc_id, callback = (error) ->) -> - client.ol_context["doc:#{doc_id}"] = "allowed" - callback(null) - - removeAccessToDoc: (client, doc_id, callback = (error) ->) -> - delete client.ol_context["doc:#{doc_id}"] - callback(null) diff --git a/services/real-time/app/coffee/ChannelManager.coffee b/services/real-time/app/coffee/ChannelManager.coffee deleted file mode 100644 index e60a145bd5..0000000000 --- a/services/real-time/app/coffee/ChannelManager.coffee +++ /dev/null @@ -1,71 +0,0 @@ -logger = require 'logger-sharelatex' -metrics = require "metrics-sharelatex" -settings = require "settings-sharelatex" - -ClientMap = new Map() # for each redis client, store a Map of subscribed channels (channelname -> subscribe promise) - -# Manage redis pubsub subscriptions for individual projects and docs, ensuring -# that we never subscribe to a channel multiple times. The socket.io side is -# handled by RoomManager. - -module.exports = ChannelManager = - getClientMapEntry: (rclient) -> - # return the per-client channel map if it exists, otherwise create and - # return an empty map for the client. - ClientMap.get(rclient) || ClientMap.set(rclient, new Map()).get(rclient) - - subscribe: (rclient, baseChannel, id) -> - clientChannelMap = @getClientMapEntry(rclient) - channel = "#{baseChannel}:#{id}" - actualSubscribe = () -> - # subscribe is happening in the foreground and it should reject - p = rclient.subscribe(channel) - p.finally () -> - if clientChannelMap.get(channel) is subscribePromise - clientChannelMap.delete(channel) - .then () -> - logger.log {channel}, "subscribed to channel" - metrics.inc "subscribe.#{baseChannel}" - .catch (err) -> - logger.error {channel, err}, "failed to subscribe to channel" - metrics.inc "subscribe.failed.#{baseChannel}" - return p - - pendingActions = clientChannelMap.get(channel) || Promise.resolve() - subscribePromise = pendingActions.then(actualSubscribe, actualSubscribe) - clientChannelMap.set(channel, subscribePromise) - logger.log {channel}, "planned to subscribe to channel" - return subscribePromise - - unsubscribe: (rclient, baseChannel, id) -> - clientChannelMap = @getClientMapEntry(rclient) - channel = "#{baseChannel}:#{id}" - actualUnsubscribe = () -> - # unsubscribe is happening in the background, it should not reject - p = rclient.unsubscribe(channel) - .finally () -> - if clientChannelMap.get(channel) is unsubscribePromise - clientChannelMap.delete(channel) - .then () -> - logger.log {channel}, "unsubscribed from channel" - metrics.inc "unsubscribe.#{baseChannel}" - .catch (err) -> - logger.error {channel, err}, "unsubscribed from channel" - metrics.inc "unsubscribe.failed.#{baseChannel}" - return p - - pendingActions = clientChannelMap.get(channel) || Promise.resolve() - unsubscribePromise = pendingActions.then(actualUnsubscribe, actualUnsubscribe) - clientChannelMap.set(channel, unsubscribePromise) - logger.log {channel}, "planned to unsubscribe from channel" - return unsubscribePromise - - publish: (rclient, baseChannel, id, data) -> - metrics.summary "redis.publish.#{baseChannel}", data.length - if id is 'all' or !settings.publishOnIndividualChannels - channel = baseChannel - else - channel = "#{baseChannel}:#{id}" - # we publish on a different client to the subscribe, so we can't - # check for the channel existing here - rclient.publish channel, data diff --git a/services/real-time/app/coffee/ConnectedUsersManager.coffee b/services/real-time/app/coffee/ConnectedUsersManager.coffee deleted file mode 100644 index 2e6536c9be..0000000000 --- a/services/real-time/app/coffee/ConnectedUsersManager.coffee +++ /dev/null @@ -1,91 +0,0 @@ -async = require("async") -Settings = require('settings-sharelatex') -logger = require("logger-sharelatex") -redis = require("redis-sharelatex") -rclient = redis.createClient(Settings.redis.realtime) -Keys = Settings.redis.realtime.key_schema - -ONE_HOUR_IN_S = 60 * 60 -ONE_DAY_IN_S = ONE_HOUR_IN_S * 24 -FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4 - -USER_TIMEOUT_IN_S = ONE_HOUR_IN_S / 4 -REFRESH_TIMEOUT_IN_S = 10 # only show clients which have responded to a refresh request in the last 10 seconds - -module.exports = - # Use the same method for when a user connects, and when a user sends a cursor - # update. This way we don't care if the connected_user key has expired when - # we receive a cursor update. - updateUserPosition: (project_id, client_id, user, cursorData, callback = (err)->)-> - logger.log project_id:project_id, client_id:client_id, "marking user as joined or connected" - - multi = rclient.multi() - - multi.sadd Keys.clientsInProject({project_id}), client_id - multi.expire Keys.clientsInProject({project_id}), FOUR_DAYS_IN_S - - multi.hset Keys.connectedUser({project_id, client_id}), "last_updated_at", Date.now() - multi.hset Keys.connectedUser({project_id, client_id}), "user_id", user._id - multi.hset Keys.connectedUser({project_id, client_id}), "first_name", user.first_name or "" - multi.hset Keys.connectedUser({project_id, client_id}), "last_name", user.last_name or "" - multi.hset Keys.connectedUser({project_id, client_id}), "email", user.email or "" - - if cursorData? - multi.hset Keys.connectedUser({project_id, client_id}), "cursorData", JSON.stringify(cursorData) - multi.expire Keys.connectedUser({project_id, client_id}), USER_TIMEOUT_IN_S - - multi.exec (err)-> - if err? - logger.err err:err, project_id:project_id, client_id:client_id, "problem marking user as connected" - callback(err) - - refreshClient: (project_id, client_id, callback = (err) ->) -> - logger.log project_id:project_id, client_id:client_id, "refreshing connected client" - multi = rclient.multi() - multi.hset Keys.connectedUser({project_id, client_id}), "last_updated_at", Date.now() - multi.expire Keys.connectedUser({project_id, client_id}), USER_TIMEOUT_IN_S - multi.exec (err)-> - if err? - logger.err err:err, project_id:project_id, client_id:client_id, "problem refreshing connected client" - callback(err) - - markUserAsDisconnected: (project_id, client_id, callback)-> - logger.log project_id:project_id, client_id:client_id, "marking user as disconnected" - multi = rclient.multi() - multi.srem Keys.clientsInProject({project_id}), client_id - multi.expire Keys.clientsInProject({project_id}), FOUR_DAYS_IN_S - multi.del Keys.connectedUser({project_id, client_id}) - multi.exec callback - - - _getConnectedUser: (project_id, client_id, callback)-> - rclient.hgetall Keys.connectedUser({project_id, client_id}), (err, result)-> - if !result? or Object.keys(result).length == 0 or !result.user_id - result = - connected : false - client_id:client_id - else - result.connected = true - result.client_id = client_id - result.client_age = (Date.now() - parseInt(result.last_updated_at,10)) / 1000 - if result.cursorData? - try - result.cursorData = JSON.parse(result.cursorData) - catch e - logger.error {err: e, project_id, client_id, cursorData: result.cursorData}, "error parsing cursorData JSON" - return callback e - callback err, result - - getConnectedUsers: (project_id, callback)-> - self = @ - rclient.smembers Keys.clientsInProject({project_id}), (err, results)-> - return callback(err) if err? - jobs = results.map (client_id)-> - (cb)-> - self._getConnectedUser(project_id, client_id, cb) - async.series jobs, (err, users = [])-> - return callback(err) if err? - users = users.filter (user) -> - user?.connected && user?.client_age < REFRESH_TIMEOUT_IN_S - callback null, users - diff --git a/services/real-time/app/coffee/DocumentUpdaterController.coffee b/services/real-time/app/coffee/DocumentUpdaterController.coffee deleted file mode 100644 index 24b6a7c525..0000000000 --- a/services/real-time/app/coffee/DocumentUpdaterController.coffee +++ /dev/null @@ -1,88 +0,0 @@ -logger = require "logger-sharelatex" -settings = require 'settings-sharelatex' -RedisClientManager = require "./RedisClientManager" -SafeJsonParse = require "./SafeJsonParse" -EventLogger = require "./EventLogger" -HealthCheckManager = require "./HealthCheckManager" -RoomManager = require "./RoomManager" -ChannelManager = require "./ChannelManager" -metrics = require "metrics-sharelatex" - -MESSAGE_SIZE_LOG_LIMIT = 1024 * 1024 # 1Mb - -module.exports = DocumentUpdaterController = - # DocumentUpdaterController is responsible for updates that come via Redis - # Pub/Sub from the document updater. - rclientList: RedisClientManager.createClientList(settings.redis.pubsub) - - listenForUpdatesFromDocumentUpdater: (io) -> - logger.log {rclients: @rclientList.length}, "listening for applied-ops events" - for rclient, i in @rclientList - rclient.subscribe "applied-ops" - rclient.on "message", (channel, message) -> - metrics.inc "rclient", 0.001 # global event rate metric - EventLogger.debugEvent(channel, message) if settings.debugEvents > 0 - DocumentUpdaterController._processMessageFromDocumentUpdater(io, channel, message) - # create metrics for each redis instance only when we have multiple redis clients - if @rclientList.length > 1 - for rclient, i in @rclientList - do (i) -> - rclient.on "message", () -> - metrics.inc "rclient-#{i}", 0.001 # per client event rate metric - @handleRoomUpdates(@rclientList) - - handleRoomUpdates: (rclientSubList) -> - roomEvents = RoomManager.eventSource() - roomEvents.on 'doc-active', (doc_id) -> - subscribePromises = for rclient in rclientSubList - ChannelManager.subscribe rclient, "applied-ops", doc_id - RoomManager.emitOnCompletion(subscribePromises, "doc-subscribed-#{doc_id}") - roomEvents.on 'doc-empty', (doc_id) -> - for rclient in rclientSubList - ChannelManager.unsubscribe rclient, "applied-ops", doc_id - - _processMessageFromDocumentUpdater: (io, channel, message) -> - SafeJsonParse.parse message, (error, message) -> - if error? - logger.error {err: error, channel}, "error parsing JSON" - return - if message.op? - if message._id? && settings.checkEventOrder - status = EventLogger.checkEventOrder("applied-ops", message._id, message) - if status is 'duplicate' - return # skip duplicate events - DocumentUpdaterController._applyUpdateFromDocumentUpdater(io, message.doc_id, message.op) - else if message.error? - DocumentUpdaterController._processErrorFromDocumentUpdater(io, message.doc_id, message.error, message) - else if message.health_check? - logger.debug {message}, "got health check message in applied ops channel" - HealthCheckManager.check channel, message.key - - _applyUpdateFromDocumentUpdater: (io, doc_id, update) -> - clientList = io.sockets.clients(doc_id) - # avoid unnecessary work if no clients are connected - if clientList.length is 0 - return - # send updates to clients - logger.log doc_id: doc_id, version: update.v, source: update.meta?.source, socketIoClients: (client.id for client in clientList), "distributing updates to clients" - seen = {} - # send messages only to unique clients (due to duplicate entries in io.sockets.clients) - for client in clientList when not seen[client.id] - seen[client.id] = true - if client.publicId == update.meta.source - logger.log doc_id: doc_id, version: update.v, source: update.meta?.source, "distributing update to sender" - client.emit "otUpdateApplied", v: update.v, doc: update.doc - else if !update.dup # Duplicate ops should just be sent back to sending client for acknowledgement - logger.log doc_id: doc_id, version: update.v, source: update.meta?.source, client_id: client.id, "distributing update to collaborator" - client.emit "otUpdateApplied", update - if Object.keys(seen).length < clientList.length - metrics.inc "socket-io.duplicate-clients", 0.1 - logger.log doc_id: doc_id, socketIoClients: (client.id for client in clientList), "discarded duplicate clients" - - _processErrorFromDocumentUpdater: (io, doc_id, error, message) -> - for client in io.sockets.clients(doc_id) - logger.warn err: error, doc_id: doc_id, client_id: client.id, "error from document updater, disconnecting client" - client.emit "otUpdateError", error, message - client.disconnect() - - diff --git a/services/real-time/app/coffee/DocumentUpdaterManager.coffee b/services/real-time/app/coffee/DocumentUpdaterManager.coffee deleted file mode 100644 index c5c5a67cb7..0000000000 --- a/services/real-time/app/coffee/DocumentUpdaterManager.coffee +++ /dev/null @@ -1,83 +0,0 @@ -request = require "request" -_ = require "underscore" -logger = require "logger-sharelatex" -settings = require "settings-sharelatex" -metrics = require("metrics-sharelatex") - -rclient = require("redis-sharelatex").createClient(settings.redis.documentupdater) -Keys = settings.redis.documentupdater.key_schema - -module.exports = DocumentUpdaterManager = - getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) -> - timer = new metrics.Timer("get-document") - url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}" - logger.log {project_id, doc_id, fromVersion}, "getting doc from document updater" - request.get url, (err, res, body) -> - timer.done() - if err? - logger.error {err, url, project_id, doc_id}, "error getting doc from doc updater" - return callback(err) - if 200 <= res.statusCode < 300 - logger.log {project_id, doc_id}, "got doc from document document updater" - try - body = JSON.parse(body) - catch error - return callback(error) - callback null, body?.lines, body?.version, body?.ranges, body?.ops - else if res.statusCode in [404, 422] - err = new Error("doc updater could not load requested ops") - err.statusCode = res.statusCode - logger.warn {err, project_id, doc_id, url, fromVersion}, "doc updater could not load requested ops" - callback err - else - err = new Error("doc updater returned a non-success status code: #{res.statusCode}") - err.statusCode = res.statusCode - logger.error {err, project_id, doc_id, url}, "doc updater returned a non-success status code: #{res.statusCode}" - callback err - - flushProjectToMongoAndDelete: (project_id, callback = ()->) -> - # this method is called when the last connected user leaves the project - logger.log project_id:project_id, "deleting project from document updater" - timer = new metrics.Timer("delete.mongo.project") - # flush the project in the background when all users have left - url = "#{settings.apis.documentupdater.url}/project/#{project_id}?background=true" + - (if settings.shutDownInProgress then "&shutdown=true" else "") - request.del url, (err, res, body)-> - timer.done() - if err? - logger.error {err, project_id}, "error deleting project from document updater" - return callback(err) - else if 200 <= res.statusCode < 300 - logger.log {project_id}, "deleted project from document updater" - return callback(null) - else - err = new Error("document updater returned a failure status code: #{res.statusCode}") - err.statusCode = res.statusCode - logger.error {err, project_id}, "document updater returned failure status code: #{res.statusCode}" - return callback(err) - - queueChange: (project_id, doc_id, change, callback = ()->)-> - allowedKeys = [ 'doc', 'op', 'v', 'dupIfSource', 'meta', 'lastV', 'hash'] - change = _.pick change, allowedKeys - jsonChange = JSON.stringify change - if jsonChange.indexOf("\u0000") != -1 - # memory corruption check - error = new Error("null bytes found in op") - logger.error err: error, project_id: project_id, doc_id: doc_id, jsonChange: jsonChange, error.message - return callback(error) - - updateSize = jsonChange.length - if updateSize > settings.maxUpdateSize - error = new Error("update is too large") - error.updateSize = updateSize - return callback(error) - - # record metric for each update added to queue - metrics.summary 'redis.pendingUpdates', updateSize, {status: 'push'} - - doc_key = "#{project_id}:#{doc_id}" - # Push onto pendingUpdates for doc_id first, because once the doc updater - # gets an entry on pending-updates-list, it starts processing. - rclient.rpush Keys.pendingUpdates({doc_id}), jsonChange, (error) -> - return callback(error) if error? - rclient.rpush "pending-updates-list", doc_key, callback diff --git a/services/real-time/app/coffee/DrainManager.coffee b/services/real-time/app/coffee/DrainManager.coffee deleted file mode 100644 index 2590a96726..0000000000 --- a/services/real-time/app/coffee/DrainManager.coffee +++ /dev/null @@ -1,39 +0,0 @@ -logger = require "logger-sharelatex" - -module.exports = DrainManager = - - startDrainTimeWindow: (io, minsToDrain)-> - drainPerMin = io.sockets.clients().length / minsToDrain - DrainManager.startDrain(io, Math.max(drainPerMin / 60, 4)) # enforce minimum drain rate - - startDrain: (io, rate) -> - # Clear out any old interval - clearInterval @interval - logger.log rate: rate, "starting drain" - if rate == 0 - return - else if rate < 1 - # allow lower drain rates - # e.g. rate=0.1 will drain one client every 10 seconds - pollingInterval = 1000 / rate - rate = 1 - else - pollingInterval = 1000 - @interval = setInterval () => - @reconnectNClients(io, rate) - , pollingInterval - - RECONNECTED_CLIENTS: {} - reconnectNClients: (io, N) -> - drainedCount = 0 - for client in io.sockets.clients() - if !@RECONNECTED_CLIENTS[client.id] - @RECONNECTED_CLIENTS[client.id] = true - logger.log {client_id: client.id}, "Asking client to reconnect gracefully" - client.emit "reconnectGracefully" - drainedCount++ - haveDrainedNClients = (drainedCount == N) - if haveDrainedNClients - break - if drainedCount < N - logger.log "All clients have been told to reconnectGracefully" diff --git a/services/real-time/app/coffee/Errors.coffee b/services/real-time/app/coffee/Errors.coffee deleted file mode 100644 index d6ef3fd71d..0000000000 --- a/services/real-time/app/coffee/Errors.coffee +++ /dev/null @@ -1,10 +0,0 @@ -CodedError = (message, code) -> - error = new Error(message) - error.name = "CodedError" - error.code = code - error.__proto__ = CodedError.prototype - return error -CodedError.prototype.__proto__ = Error.prototype - -module.exports = Errors = - CodedError: CodedError diff --git a/services/real-time/app/coffee/EventLogger.coffee b/services/real-time/app/coffee/EventLogger.coffee deleted file mode 100644 index 332973659b..0000000000 --- a/services/real-time/app/coffee/EventLogger.coffee +++ /dev/null @@ -1,60 +0,0 @@ -logger = require 'logger-sharelatex' -metrics = require 'metrics-sharelatex' -settings = require 'settings-sharelatex' - -# keep track of message counters to detect duplicate and out of order events -# messsage ids have the format "UNIQUEHOSTKEY-COUNTER" - -EVENT_LOG_COUNTER = {} -EVENT_LOG_TIMESTAMP = {} -EVENT_LAST_CLEAN_TIMESTAMP = 0 - -# counter for debug logs -COUNTER = 0 - -module.exports = EventLogger = - - MAX_STALE_TIME_IN_MS: 3600 * 1000 - - debugEvent: (channel, message) -> - if settings.debugEvents > 0 - logger.log {channel:channel, message:message, counter: COUNTER++}, "logging event" - settings.debugEvents-- - - checkEventOrder: (channel, message_id, message) -> - return if typeof(message_id) isnt 'string' - return if !(result = message_id.match(/^(.*)-(\d+)$/)) - key = result[1] - count = parseInt(result[2], 0) - if !(count >= 0)# ignore checks if counter is not present - return - # store the last count in a hash for each host - previous = EventLogger._storeEventCount(key, count) - if !previous? || count == (previous + 1) - metrics.inc "event.#{channel}.valid", 0.001 # downsample high rate docupdater events - return # order is ok - if (count == previous) - metrics.inc "event.#{channel}.duplicate" - logger.warn {channel:channel, message_id:message_id}, "duplicate event" - return "duplicate" - else - metrics.inc "event.#{channel}.out-of-order" - logger.warn {channel:channel, message_id:message_id, key:key, previous: previous, count:count}, "out of order event" - return "out-of-order" - - _storeEventCount: (key, count) -> - previous = EVENT_LOG_COUNTER[key] - now = Date.now() - EVENT_LOG_COUNTER[key] = count - EVENT_LOG_TIMESTAMP[key] = now - # periodically remove old counts - if (now - EVENT_LAST_CLEAN_TIMESTAMP) > EventLogger.MAX_STALE_TIME_IN_MS - EventLogger._cleanEventStream(now) - EVENT_LAST_CLEAN_TIMESTAMP = now - return previous - - _cleanEventStream: (now) -> - for key, timestamp of EVENT_LOG_TIMESTAMP - if (now - timestamp) > EventLogger.MAX_STALE_TIME_IN_MS - delete EVENT_LOG_COUNTER[key] - delete EVENT_LOG_TIMESTAMP[key] \ No newline at end of file diff --git a/services/real-time/app/coffee/HealthCheckManager.coffee b/services/real-time/app/coffee/HealthCheckManager.coffee deleted file mode 100644 index bcd3e2ed07..0000000000 --- a/services/real-time/app/coffee/HealthCheckManager.coffee +++ /dev/null @@ -1,52 +0,0 @@ -metrics = require "metrics-sharelatex" -logger = require("logger-sharelatex") - -os = require "os" -HOST = os.hostname() -PID = process.pid -COUNT = 0 - -CHANNEL_MANAGER = {} # hash of event checkers by channel name -CHANNEL_ERROR = {} # error status by channel name - -module.exports = 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 = 1000) -> - # unique event string - @id = "host=#{HOST}:pid=#{PID}:count=#{COUNT++}" - # count of number of times the event is received - @count = 0 - # after a timeout check the status of the count - @handler = setTimeout () => - @setStatus() - , timeout - # use a timer to record the latency of the channel - @timer = new metrics.Timer("event.#{@channel}.latency") - # keep a record of these objects to dispatch on - CHANNEL_MANAGER[@channel] = @ - processEvent: (id) -> - # if this is our event record it - if id == @id - @count++ - @timer?.done() - @timer = null # only time the latency of the first event - setStatus: () -> - # if we saw the event anything other than a single time that is an error - if @count != 1 - logger.err channel:@channel, count:@count, id:@id, "redis channel health check error" - error = (@count != 1) - CHANNEL_ERROR[@channel] = error - - # class methods - @check: (channel, id) -> - # dispatch event to manager for channel - CHANNEL_MANAGER[channel]?.processEvent id - @status: () -> - # return status of all channels for logging - return CHANNEL_ERROR - @isFailing: () -> - # check if any channel status is bad - for channel, error of CHANNEL_ERROR - return true if error is true - return false diff --git a/services/real-time/app/coffee/HttpApiController.coffee b/services/real-time/app/coffee/HttpApiController.coffee deleted file mode 100644 index 299d198f57..0000000000 --- a/services/real-time/app/coffee/HttpApiController.coffee +++ /dev/null @@ -1,35 +0,0 @@ -WebsocketLoadBalancer = require "./WebsocketLoadBalancer" -DrainManager = require "./DrainManager" -logger = require "logger-sharelatex" - -module.exports = HttpApiController = - sendMessage: (req, res, next) -> - logger.log {message: req.params.message}, "sending message" - if Array.isArray(req.body) - for payload in req.body - WebsocketLoadBalancer.emitToRoom req.params.project_id, req.params.message, payload - else - WebsocketLoadBalancer.emitToRoom req.params.project_id, req.params.message, req.body - res.send 204 # No content - - startDrain: (req, res, next) -> - io = req.app.get("io") - rate = req.query.rate or "4" - rate = parseFloat(rate) || 0 - logger.log {rate}, "setting client drain rate" - DrainManager.startDrain io, rate - res.send 204 - - disconnectClient: (req, res, next) -> - io = req.app.get("io") - client_id = req.params.client_id - client = io.sockets.sockets[client_id] - - if !client - logger.info({client_id}, "api: client already disconnected") - res.sendStatus(404) - return - logger.warn({client_id}, "api: requesting client disconnect") - client.on "disconnect", () -> - res.sendStatus(204) - client.disconnect() diff --git a/services/real-time/app/coffee/HttpController.coffee b/services/real-time/app/coffee/HttpController.coffee deleted file mode 100644 index 1fc74e8c16..0000000000 --- a/services/real-time/app/coffee/HttpController.coffee +++ /dev/null @@ -1,35 +0,0 @@ -async = require "async" - -module.exports = 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 ShareLaTeX, and is only used for getting stats about connected clients, - # and for checking internal state in acceptance tests. The acceptances tests - # should provide appropriate coverage. - _getConnectedClientView: (ioClient, callback = (error, client) ->) -> - client_id = ioClient.id - {project_id, user_id, first_name, last_name, email, connected_time} = ioClient.ol_context - client = {client_id, project_id, user_id, first_name, last_name, email, connected_time} - client.rooms = [] - for name, joined of ioClient.manager.roomClients[client_id] - if joined and name != "" - client.rooms.push name.replace(/^\//, "") # Remove leading / - callback(null, client) - - getConnectedClients: (req, res, next) -> - io = req.app.get("io") - ioClients = io.sockets.clients() - async.map ioClients, HttpController._getConnectedClientView, (error, clients) -> - return next(error) if error? - res.json clients - - getConnectedClient: (req, res, next) -> - {client_id} = req.params - io = req.app.get("io") - ioClient = io.sockets.sockets[client_id] - if !ioClient - res.sendStatus(404) - return - HttpController._getConnectedClientView ioClient, (error, client) -> - return next(error) if error? - res.json client diff --git a/services/real-time/app/coffee/RedisClientManager.coffee b/services/real-time/app/coffee/RedisClientManager.coffee deleted file mode 100644 index 1d573df9b8..0000000000 --- a/services/real-time/app/coffee/RedisClientManager.coffee +++ /dev/null @@ -1,18 +0,0 @@ -redis = require("redis-sharelatex") -logger = require 'logger-sharelatex' - -module.exports = RedisClientManager = - createClientList: (configs...) -> - # create a dynamic list of redis clients, excluding any configurations which are not defined - clientList = for x in configs when x? - redisType = if x.cluster? - "cluster" - else if x.sentinels? - "sentinel" - else if x.host? - "single" - else - "unknown" - logger.log {redis: redisType}, "creating redis client" - redis.createClient(x) - return clientList \ No newline at end of file diff --git a/services/real-time/app/coffee/RoomManager.coffee b/services/real-time/app/coffee/RoomManager.coffee deleted file mode 100644 index 25684ed558..0000000000 --- a/services/real-time/app/coffee/RoomManager.coffee +++ /dev/null @@ -1,110 +0,0 @@ -logger = require 'logger-sharelatex' -metrics = require "metrics-sharelatex" -{EventEmitter} = require 'events' - -IdMap = new Map() # keep track of whether ids are from projects or docs -RoomEvents = new EventEmitter() # emits {project,doc}-active and {project,doc}-empty events - -# Manage socket.io rooms for individual projects and docs -# -# The first time someone joins a project or doc we emit a 'project-active' or -# 'doc-active' event. -# -# When the last person leaves a project or doc, we emit 'project-empty' or -# 'doc-empty' event. -# -# The pubsub side is handled by ChannelManager - -module.exports = RoomManager = - - joinProject: (client, project_id, callback = () ->) -> - @joinEntity client, "project", project_id, callback - - joinDoc: (client, doc_id, callback = () ->) -> - @joinEntity client, "doc", doc_id, callback - - leaveDoc: (client, doc_id) -> - @leaveEntity client, "doc", doc_id - - leaveProjectAndDocs: (client) -> - # what rooms is this client in? we need to leave them all. socket.io - # will cause us to leave the rooms, so we only need to manage our - # channel subscriptions... but it will be safer if we leave them - # explicitly, and then socket.io will just regard this as a client that - # has not joined any rooms and do a final disconnection. - roomsToLeave = @_roomsClientIsIn(client) - logger.log {client: client.id, roomsToLeave: roomsToLeave}, "client leaving project" - for id in roomsToLeave - entity = IdMap.get(id) - @leaveEntity client, entity, id - - emitOnCompletion: (promiseList, eventName) -> - Promise.all(promiseList) - .then(() -> RoomEvents.emit(eventName)) - .catch((err) -> RoomEvents.emit(eventName, err)) - - eventSource: () -> - return RoomEvents - - joinEntity: (client, entity, id, callback) -> - beforeCount = @_clientsInRoom(client, id) - # client joins room immediately but joinDoc request does not complete - # until room is subscribed - client.join id - # is this a new room? if so, subscribe - if beforeCount == 0 - logger.log {entity, id}, "room is now active" - RoomEvents.once "#{entity}-subscribed-#{id}", (err) -> - # only allow the client to join when all the relevant channels have subscribed - logger.log {client: client.id, entity, id, beforeCount}, "client joined new room and subscribed to channel" - callback(err) - RoomEvents.emit "#{entity}-active", id - IdMap.set(id, entity) - # keep track of the number of listeners - metrics.gauge "room-listeners", RoomEvents.eventNames().length - else - logger.log {client: client.id, entity, id, beforeCount}, "client joined existing room" - client.join id - callback() - - leaveEntity: (client, entity, id) -> - # Ignore any requests to leave when the client is not actually in the - # room. This can happen if the client sends spurious leaveDoc requests - # for old docs after a reconnection. - # This can now happen all the time, as we skip the join for clients that - # disconnect before joinProject/joinDoc completed. - if !@_clientAlreadyInRoom(client, id) - logger.log {client: client.id, entity, id}, "ignoring request from client to leave room it is not in" - return - client.leave id - afterCount = @_clientsInRoom(client, id) - logger.log {client: client.id, entity, id, afterCount}, "client left room" - # is the room now empty? if so, unsubscribe - if !entity? - logger.error {entity: id}, "unknown entity when leaving with id" - return - if afterCount == 0 - logger.log {entity, id}, "room is now empty" - RoomEvents.emit "#{entity}-empty", id - IdMap.delete(id) - metrics.gauge "room-listeners", RoomEvents.eventNames().length - - # internal functions below, these access socket.io rooms data directly and - # will need updating for socket.io v2 - - _clientsInRoom: (client, room) -> - nsp = client.namespace.name - name = (nsp + '/') + room; - return (client.manager?.rooms?[name] || []).length - - _roomsClientIsIn: (client) -> - roomList = for fullRoomPath of client.manager.roomClients?[client.id] when fullRoomPath isnt '' - # strip socket.io prefix from room to get original id - [prefix, room] = fullRoomPath.split('/', 2) - room - return roomList - - _clientAlreadyInRoom: (client, room) -> - nsp = client.namespace.name - name = (nsp + '/') + room; - return client.manager.roomClients?[client.id]?[name] \ No newline at end of file diff --git a/services/real-time/app/coffee/Router.coffee b/services/real-time/app/coffee/Router.coffee deleted file mode 100644 index 3d891f1476..0000000000 --- a/services/real-time/app/coffee/Router.coffee +++ /dev/null @@ -1,188 +0,0 @@ -metrics = require "metrics-sharelatex" -logger = require "logger-sharelatex" -settings = require "settings-sharelatex" -WebsocketController = require "./WebsocketController" -HttpController = require "./HttpController" -HttpApiController = require "./HttpApiController" -bodyParser = require "body-parser" -base64id = require("base64id") - -basicAuth = require('basic-auth-connect') -httpAuth = basicAuth (user, pass)-> - isValid = user == settings.internal.realTime.user and pass == settings.internal.realTime.pass - if !isValid - logger.err user:user, pass:pass, "invalid login details" - return isValid - -module.exports = Router = - _handleError: (callback = ((error) ->), error, client, method, attrs = {}) -> - for key in ["project_id", "doc_id", "user_id"] - attrs[key] = client.ol_context[key] - attrs.client_id = client.id - attrs.err = error - if error.name == "CodedError" - logger.warn attrs, error.message, code: error.code - return callback {message: error.message, code: error.code} - if error.message == 'unexpected arguments' - # the payload might be very large, put it on level info - logger.log attrs, 'unexpected arguments' - metrics.inc 'unexpected-arguments', 1, { status: method } - return callback { message: error.message } - if error.message in ["not authorized", "doc updater could not load requested ops", "no project_id found on client"] - logger.warn attrs, error.message - return callback {message: error.message} - else - logger.error attrs, "server side error in #{method}" - # Don't return raw error to prevent leaking server side info - return callback {message: "Something went wrong in real-time service"} - - _handleInvalidArguments: (client, method, args) -> - error = new Error("unexpected arguments") - callback = args[args.length - 1] - if typeof callback != 'function' - callback = (() ->) - attrs = {arguments: args} - Router._handleError(callback, error, client, method, attrs) - - configure: (app, io, session) -> - app.set("io", io) - app.get "/clients", HttpController.getConnectedClients - app.get "/clients/:client_id", HttpController.getConnectedClient - - app.post "/project/:project_id/message/:message", httpAuth, bodyParser.json(limit: "5mb"), HttpApiController.sendMessage - - app.post "/drain", httpAuth, HttpApiController.startDrain - app.post "/client/:client_id/disconnect", httpAuth, HttpApiController.disconnectClient - - session.on 'connection', (error, client, session) -> - # init client context, we may access it in Router._handleError before - # setting any values - client.ol_context = {} - - client?.on "error", (err) -> - logger.err { clientErr: err }, "socket.io client error" - if client.connected - client.emit("reconnectGracefully") - client.disconnect() - - if settings.shutDownInProgress - client.emit("connectionRejected", {message: "retry"}) - client.disconnect() - return - - if client? and error?.message?.match(/could not look up session by key/) - logger.warn err: error, client: client?, session: session?, "invalid session" - # tell the client to reauthenticate if it has an invalid session key - client.emit("connectionRejected", {message: "invalid session"}) - client.disconnect() - return - - if error? - logger.err err: error, client: client?, session: session?, "error when client connected" - client?.emit("connectionRejected", {message: "error"}) - client?.disconnect() - return - - # send positive confirmation that the client has a valid connection - client.publicId = 'P.' + base64id.generateId() - client.emit("connectionAccepted", null, client.publicId) - - metrics.inc('socket-io.connection') - metrics.gauge('socket-io.clients', io.sockets.clients()?.length) - - logger.log session: session, client_id: client.id, "client connected" - - if session?.passport?.user? - user = session.passport.user - else if session?.user? - user = session.user - else - user = {_id: "anonymous-user"} - - client.on "joinProject", (data = {}, callback) -> - if typeof callback != 'function' - return Router._handleInvalidArguments(client, 'joinProject', arguments) - - if data.anonymousAccessToken - user.anonymousAccessToken = data.anonymousAccessToken - WebsocketController.joinProject client, user, data.project_id, (err, args...) -> - if err? - Router._handleError callback, err, client, "joinProject", {project_id: data.project_id, user_id: user?.id} - else - callback(null, args...) - - client.on "disconnect", () -> - metrics.inc('socket-io.disconnect') - metrics.gauge('socket-io.clients', io.sockets.clients()?.length - 1) - - WebsocketController.leaveProject io, client, (err) -> - if err? - Router._handleError (() ->), err, client, "leaveProject" - - # Variadic. The possible arguments: - # doc_id, callback - # doc_id, fromVersion, callback - # doc_id, options, callback - # doc_id, fromVersion, options, callback - client.on "joinDoc", (doc_id, fromVersion, options, callback) -> - if typeof fromVersion == "function" and !options - callback = fromVersion - fromVersion = -1 - options = {} - else if typeof fromVersion == "number" and typeof options == "function" - callback = options - options = {} - else if typeof fromVersion == "object" and typeof options == "function" - callback = options - options = fromVersion - fromVersion = -1 - else if typeof fromVersion == "number" and typeof options == "object" and typeof callback == 'function' - # Called with 4 args, things are as expected - else - return Router._handleInvalidArguments(client, 'joinDoc', arguments) - - WebsocketController.joinDoc client, doc_id, fromVersion, options, (err, args...) -> - if err? - Router._handleError callback, err, client, "joinDoc", {doc_id, fromVersion} - else - callback(null, args...) - - client.on "leaveDoc", (doc_id, callback) -> - if typeof callback != 'function' - return Router._handleInvalidArguments(client, 'leaveDoc', arguments) - - WebsocketController.leaveDoc client, doc_id, (err, args...) -> - if err? - Router._handleError callback, err, client, "leaveDoc" - else - callback(null, args...) - - client.on "clientTracking.getConnectedUsers", (callback = (error, users) ->) -> - if typeof callback != 'function' - return Router._handleInvalidArguments(client, 'clientTracking.getConnectedUsers', arguments) - - WebsocketController.getConnectedUsers client, (err, users) -> - if err? - Router._handleError callback, err, client, "clientTracking.getConnectedUsers" - else - callback(null, users) - - client.on "clientTracking.updatePosition", (cursorData, callback = (error) ->) -> - if typeof callback != 'function' - return Router._handleInvalidArguments(client, 'clientTracking.updatePosition', arguments) - - WebsocketController.updateClientPosition client, cursorData, (err) -> - if err? - Router._handleError callback, err, client, "clientTracking.updatePosition" - else - callback() - - client.on "applyOtUpdate", (doc_id, update, callback = (error) ->) -> - if typeof callback != 'function' - return Router._handleInvalidArguments(client, 'applyOtUpdate', arguments) - - WebsocketController.applyOtUpdate client, doc_id, update, (err) -> - if err? - Router._handleError callback, err, client, "applyOtUpdate", {doc_id, update} - else - callback() diff --git a/services/real-time/app/coffee/SafeJsonParse.coffee b/services/real-time/app/coffee/SafeJsonParse.coffee deleted file mode 100644 index afeb72f96e..0000000000 --- a/services/real-time/app/coffee/SafeJsonParse.coffee +++ /dev/null @@ -1,13 +0,0 @@ -Settings = require "settings-sharelatex" -logger = require "logger-sharelatex" - -module.exports = - parse: (data, callback = (error, parsed) ->) -> - if data.length > Settings.maxUpdateSize - logger.error {head: data.slice(0,1024), length: data.length}, "data too large to parse" - return callback new Error("data too large to parse") - try - parsed = JSON.parse(data) - catch e - return callback e - callback null, parsed \ No newline at end of file diff --git a/services/real-time/app/coffee/SessionSockets.coffee b/services/real-time/app/coffee/SessionSockets.coffee deleted file mode 100644 index 229e07b3bb..0000000000 --- a/services/real-time/app/coffee/SessionSockets.coffee +++ /dev/null @@ -1,23 +0,0 @@ -{EventEmitter} = require('events') - -module.exports = (io, sessionStore, cookieParser, cookieName) -> - missingSessionError = new Error('could not look up session by key') - - sessionSockets = new EventEmitter() - next = (error, socket, session) -> - sessionSockets.emit 'connection', error, socket, session - - io.on 'connection', (socket) -> - req = socket.handshake - cookieParser req, {}, () -> - sessionId = req.signedCookies and req.signedCookies[cookieName] - if not sessionId - return next(missingSessionError, socket) - sessionStore.get sessionId, (error, session) -> - if error - return next(error, socket) - if not session - return next(missingSessionError, socket) - next(null, socket, session) - - return sessionSockets diff --git a/services/real-time/app/coffee/WebApiManager.coffee b/services/real-time/app/coffee/WebApiManager.coffee deleted file mode 100644 index 3c0551a815..0000000000 --- a/services/real-time/app/coffee/WebApiManager.coffee +++ /dev/null @@ -1,38 +0,0 @@ -request = require "request" -settings = require "settings-sharelatex" -logger = require "logger-sharelatex" -{ CodedError } = require "./Errors" - -module.exports = WebApiManager = - joinProject: (project_id, user, callback = (error, project, privilegeLevel, isRestrictedUser) ->) -> - user_id = user._id - logger.log {project_id, user_id}, "sending join project request to web" - url = "#{settings.apis.web.url}/project/#{project_id}/join" - headers = {} - if user.anonymousAccessToken? - headers['x-sl-anonymous-access-token'] = user.anonymousAccessToken - request.post { - url: url - qs: {user_id} - auth: - user: settings.apis.web.user - pass: settings.apis.web.pass - sendImmediately: true - json: true - jar: false - headers: headers - }, (error, response, data) -> - return callback(error) if error? - if 200 <= response.statusCode < 300 - if !data? || !data?.project? - err = new Error('no data returned from joinProject request') - logger.error {err, project_id, user_id}, "error accessing web api" - return callback(err) - callback null, data.project, data.privilegeLevel, data.isRestrictedUser - else if response.statusCode == 429 - logger.log(project_id, user_id, "rate-limit hit when joining project") - callback(new CodedError("rate-limit hit when joining project", "TooManyRequests")) - else - err = new Error("non-success status code from web: #{response.statusCode}") - logger.error {err, project_id, user_id}, "error accessing web api" - callback err diff --git a/services/real-time/app/coffee/WebsocketController.coffee b/services/real-time/app/coffee/WebsocketController.coffee deleted file mode 100644 index f93e67f2a2..0000000000 --- a/services/real-time/app/coffee/WebsocketController.coffee +++ /dev/null @@ -1,276 +0,0 @@ -logger = require "logger-sharelatex" -metrics = require "metrics-sharelatex" -settings = require "settings-sharelatex" -WebApiManager = require "./WebApiManager" -AuthorizationManager = require "./AuthorizationManager" -DocumentUpdaterManager = require "./DocumentUpdaterManager" -ConnectedUsersManager = require "./ConnectedUsersManager" -WebsocketLoadBalancer = require "./WebsocketLoadBalancer" -RoomManager = require "./RoomManager" - -module.exports = 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. - PROTOCOL_VERSION: 2 - - joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) -> - if client.disconnected - metrics.inc('editor.join-project.disconnected', 1, {status: 'immediately'}) - return callback() - - user_id = user?._id - logger.log {user_id, project_id, client_id: client.id}, "user joining project" - metrics.inc "editor.join-project" - WebApiManager.joinProject project_id, user, (error, project, privilegeLevel, isRestrictedUser) -> - return callback(error) if error? - if client.disconnected - metrics.inc('editor.join-project.disconnected', 1, {status: 'after-web-api-call'}) - return callback() - - if !privilegeLevel or privilegeLevel == "" - err = new Error("not authorized") - logger.warn {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project" - return callback(err) - - client.ol_context = {} - client.ol_context["privilege_level"] = privilegeLevel - client.ol_context["user_id"] = user_id - client.ol_context["project_id"] = project_id - client.ol_context["owner_id"] = project?.owner?._id - client.ol_context["first_name"] = user?.first_name - client.ol_context["last_name"] = user?.last_name - client.ol_context["email"] = user?.email - client.ol_context["connected_time"] = new Date() - client.ol_context["signup_date"] = user?.signUpDate - client.ol_context["login_count"] = user?.loginCount - client.ol_context["is_restricted_user"] = !!(isRestrictedUser) - - RoomManager.joinProject client, project_id, (err) -> - return callback(err) if err - logger.log {user_id, project_id, client_id: client.id}, "user joined project" - callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION - - # No need to block for setting the user as connected in the cursor tracking - ConnectedUsersManager.updateUserPosition project_id, client.publicId, user, null, () -> - - # We want to flush a project if there are no more (local) connected clients - # but we need to wait for the triggering client to disconnect. How long we wait - # is determined by FLUSH_IF_EMPTY_DELAY. - FLUSH_IF_EMPTY_DELAY: 500 #ms - leaveProject: (io, client, callback = (error) ->) -> - {project_id, user_id} = client.ol_context - return callback() unless project_id # client did not join project - - metrics.inc "editor.leave-project" - logger.log {project_id, user_id, client_id: client.id}, "client leaving project" - WebsocketLoadBalancer.emitToRoom project_id, "clientTracking.clientDisconnected", client.publicId - - # We can do this in the background - ConnectedUsersManager.markUserAsDisconnected project_id, client.publicId, (err) -> - if err? - logger.error {err, project_id, user_id, client_id: client.id}, "error marking client as disconnected" - - RoomManager.leaveProjectAndDocs(client) - setTimeout () -> - remainingClients = io.sockets.clients(project_id) - if remainingClients.length == 0 - # Flush project in the background - DocumentUpdaterManager.flushProjectToMongoAndDelete project_id, (err) -> - if err? - logger.error {err, project_id, user_id, client_id: client.id}, "error flushing to doc updater after leaving project" - callback() - , WebsocketController.FLUSH_IF_EMPTY_DELAY - - joinDoc: (client, doc_id, fromVersion = -1, options, callback = (error, doclines, version, ops, ranges) ->) -> - if client.disconnected - metrics.inc('editor.join-doc.disconnected', 1, {status: 'immediately'}) - return callback() - - metrics.inc "editor.join-doc" - {project_id, user_id, is_restricted_user} = client.ol_context - return callback(new Error("no project_id found on client")) if !project_id? - logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc" - - AuthorizationManager.assertClientCanViewProject client, (error) -> - return callback(error) if error? - # ensure the per-doc applied-ops channel is subscribed before sending the - # doc to the client, so that no events are missed. - RoomManager.joinDoc client, doc_id, (error) -> - return callback(error) if error? - if client.disconnected - metrics.inc('editor.join-doc.disconnected', 1, {status: 'after-joining-room'}) - # the client will not read the response anyways - return callback() - - DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ranges, ops) -> - return callback(error) if error? - if client.disconnected - metrics.inc('editor.join-doc.disconnected', 1, {status: 'after-doc-updater-call'}) - # the client will not read the response anyways - return callback() - - if is_restricted_user and ranges?.comments? - ranges.comments = [] - - # Encode any binary bits of data so it can go via WebSockets - # See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html - encodeForWebsockets = (text) -> unescape(encodeURIComponent(text)) - escapedLines = [] - for line in lines - try - line = encodeForWebsockets(line) - catch err - logger.err {err, project_id, doc_id, fromVersion, line, client_id: client.id}, "error encoding line uri component" - return callback(err) - escapedLines.push line - if options.encodeRanges - try - for comment in ranges?.comments or [] - comment.op.c = encodeForWebsockets(comment.op.c) if comment.op.c? - for change in ranges?.changes or [] - change.op.i = encodeForWebsockets(change.op.i) if change.op.i? - change.op.d = encodeForWebsockets(change.op.d) if change.op.d? - catch err - logger.err {err, project_id, doc_id, fromVersion, ranges, client_id: client.id}, "error encoding range uri component" - return callback(err) - - AuthorizationManager.addAccessToDoc client, doc_id - logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joined doc" - callback null, escapedLines, version, ops, ranges - - leaveDoc: (client, doc_id, callback = (error) ->) -> - # client may have disconnected, but we have to cleanup internal state. - metrics.inc "editor.leave-doc" - {project_id, user_id} = client.ol_context - logger.log {user_id, project_id, doc_id, client_id: client.id}, "client leaving doc" - RoomManager.leaveDoc(client, doc_id) - # we could remove permission when user leaves a doc, but because - # the connection is per-project, we continue to allow access - # after the initial joinDoc since we know they are already authorised. - ## AuthorizationManager.removeAccessToDoc client, doc_id - callback() - updateClientPosition: (client, cursorData, callback = (error) ->) -> - if client.disconnected - # do not create a ghost entry in redis - return callback() - - metrics.inc "editor.update-client-position", 0.1 - {project_id, first_name, last_name, email, user_id} = client.ol_context - logger.log {user_id, project_id, client_id: client.id, cursorData: cursorData}, "updating client position" - - AuthorizationManager.assertClientCanViewProjectAndDoc client, cursorData.doc_id, (error) -> - if error? - logger.warn {err: error, client_id: client.id, project_id, user_id}, "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet." - return callback() - cursorData.id = client.publicId - cursorData.user_id = user_id if user_id? - cursorData.email = email if email? - # Don't store anonymous users in redis to avoid influx - if !user_id or user_id == 'anonymous-user' - cursorData.name = "" - callback() - else - cursorData.name = if first_name && last_name - "#{first_name} #{last_name}" - else if first_name - first_name - else if last_name - last_name - else - "" - ConnectedUsersManager.updateUserPosition(project_id, client.publicId, { - first_name: first_name, - last_name: last_name, - email: email, - _id: user_id - }, { - row: cursorData.row, - column: cursorData.column, - doc_id: cursorData.doc_id - }, callback) - WebsocketLoadBalancer.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData) - - CLIENT_REFRESH_DELAY: 1000 - getConnectedUsers: (client, callback = (error, users) ->) -> - if client.disconnected - # they are not interested anymore, skip the redis lookups - return callback() - - metrics.inc "editor.get-connected-users" - {project_id, user_id, is_restricted_user} = client.ol_context - if is_restricted_user - return callback(null, []) - return callback(new Error("no project_id found on client")) if !project_id? - logger.log {user_id, project_id, client_id: client.id}, "getting connected users" - AuthorizationManager.assertClientCanViewProject client, (error) -> - return callback(error) if error? - WebsocketLoadBalancer.emitToRoom project_id, 'clientTracking.refresh' - setTimeout () -> - ConnectedUsersManager.getConnectedUsers project_id, (error, users) -> - return callback(error) if error? - callback null, users - logger.log {user_id, project_id, client_id: client.id}, "got connected users" - , WebsocketController.CLIENT_REFRESH_DELAY - - applyOtUpdate: (client, doc_id, update, callback = (error) ->) -> - # client may have disconnected, but we can submit their update to doc-updater anyways. - {user_id, project_id} = client.ol_context - return callback(new Error("no project_id found on client")) if !project_id? - - WebsocketController._assertClientCanApplyUpdate client, doc_id, update, (error) -> - if error? - logger.warn {err: error, doc_id, client_id: client.id, version: update.v}, "client is not authorized to make update" - setTimeout () -> - # Disconnect, but give the client the chance to receive the error - client.disconnect() - , 100 - return callback(error) - update.meta ||= {} - update.meta.source = client.publicId - update.meta.user_id = user_id - metrics.inc "editor.doc-update", 0.3 - - logger.log {user_id, doc_id, project_id, client_id: client.id, version: update.v}, "sending update to doc updater" - - DocumentUpdaterManager.queueChange project_id, doc_id, update, (error) -> - if error?.message == "update is too large" - metrics.inc "update_too_large" - updateSize = error.updateSize - logger.warn({user_id, project_id, doc_id, updateSize}, "update is too large") - - # mark the update as received -- the client should not send it again! - callback() - - # trigger an out-of-sync error - message = {project_id, doc_id, error: "update is too large"} - setTimeout () -> - if client.disconnected - # skip the message broadcast, the client has moved on - return metrics.inc('editor.doc-update.disconnected', 1, {status:'at-otUpdateError'}) - client.emit "otUpdateError", message.error, message - client.disconnect() - , 100 - return - - if error? - logger.error {err: error, project_id, doc_id, client_id: client.id, version: update.v}, "document was not available for update" - client.disconnect() - callback(error) - - _assertClientCanApplyUpdate: (client, doc_id, update, callback) -> - AuthorizationManager.assertClientCanEditProjectAndDoc client, doc_id, (error) -> - if error? - if error.message == "not authorized" and WebsocketController._isCommentUpdate(update) - # This might be a comment op, which we only need read-only priveleges for - AuthorizationManager.assertClientCanViewProjectAndDoc client, doc_id, callback - else - return callback(error) - else - return callback(null) - - _isCommentUpdate: (update) -> - for op in update.op - if !op.c? - return false - return true diff --git a/services/real-time/app/coffee/WebsocketLoadBalancer.coffee b/services/real-time/app/coffee/WebsocketLoadBalancer.coffee deleted file mode 100644 index 209ec0bb08..0000000000 --- a/services/real-time/app/coffee/WebsocketLoadBalancer.coffee +++ /dev/null @@ -1,104 +0,0 @@ -Settings = require 'settings-sharelatex' -logger = require 'logger-sharelatex' -RedisClientManager = require "./RedisClientManager" -SafeJsonParse = require "./SafeJsonParse" -EventLogger = require "./EventLogger" -HealthCheckManager = require "./HealthCheckManager" -RoomManager = require "./RoomManager" -ChannelManager = require "./ChannelManager" -ConnectedUsersManager = require "./ConnectedUsersManager" - -RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [ - 'connectionAccepted', - 'otUpdateApplied', - 'otUpdateError', - 'joinDoc', - 'reciveNewDoc', - 'reciveNewFile', - 'reciveNewFolder', - 'removeEntity' -] - -module.exports = WebsocketLoadBalancer = - rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub) - rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub) - - emitToRoom: (room_id, message, payload...) -> - if !room_id? - logger.warn {message, payload}, "no room_id provided, ignoring emitToRoom" - return - data = JSON.stringify - room_id: room_id - message: message - payload: payload - logger.log {room_id, message, payload, length: data.length}, "emitting to room" - - for rclientPub in @rclientPubList - ChannelManager.publish rclientPub, "editor-events", room_id, data - - emitToAll: (message, payload...) -> - @emitToRoom "all", message, payload... - - listenForEditorEvents: (io) -> - logger.log {rclients: @rclientPubList.length}, "publishing editor events" - logger.log {rclients: @rclientSubList.length}, "listening for editor events" - for rclientSub in @rclientSubList - rclientSub.subscribe "editor-events" - rclientSub.on "message", (channel, message) -> - EventLogger.debugEvent(channel, message) if Settings.debugEvents > 0 - WebsocketLoadBalancer._processEditorEvent io, channel, message - @handleRoomUpdates(@rclientSubList) - - handleRoomUpdates: (rclientSubList) -> - roomEvents = RoomManager.eventSource() - roomEvents.on 'project-active', (project_id) -> - subscribePromises = for rclient in rclientSubList - ChannelManager.subscribe rclient, "editor-events", project_id - RoomManager.emitOnCompletion(subscribePromises, "project-subscribed-#{project_id}") - roomEvents.on 'project-empty', (project_id) -> - for rclient in rclientSubList - ChannelManager.unsubscribe rclient, "editor-events", project_id - - _processEditorEvent: (io, channel, message) -> - SafeJsonParse.parse message, (error, message) -> - if error? - logger.error {err: error, channel}, "error parsing JSON" - return - if message.room_id == "all" - io.sockets.emit(message.message, message.payload...) - else if message.message is 'clientTracking.refresh' && message.room_id? - clientList = io.sockets.clients(message.room_id) - logger.log {channel:channel, message: message.message, room_id: message.room_id, message_id: message._id, socketIoClients: (client.id for client in clientList)}, "refreshing client list" - for client in clientList - ConnectedUsersManager.refreshClient(message.room_id, client.publicId) - else if message.room_id? - if message._id? && Settings.checkEventOrder - status = EventLogger.checkEventOrder("editor-events", message._id, message) - if status is "duplicate" - return # skip duplicate events - - is_restricted_message = message.message not in RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST - - # send messages only to unique clients (due to duplicate entries in io.sockets.clients) - clientList = io.sockets.clients(message.room_id) - .filter((client) -> - !(is_restricted_message && client.ol_context['is_restricted_user']) - ) - - # avoid unnecessary work if no clients are connected - return if clientList.length is 0 - logger.log { - channel: channel, - message: message.message, - room_id: message.room_id, - message_id: message._id, - socketIoClients: (client.id for client in clientList) - }, "distributing event to clients" - seen = {} - for client in clientList - if !seen[client.id] - seen[client.id] = true - client.emit(message.message, message.payload...) - else if message.health_check? - logger.debug {message}, "got health check message in editor events channel" - HealthCheckManager.check channel, message.key diff --git a/services/real-time/app/js/AuthorizationManager.js b/services/real-time/app/js/AuthorizationManager.js new file mode 100644 index 0000000000..15607a898a --- /dev/null +++ b/services/real-time/app/js/AuthorizationManager.js @@ -0,0 +1,111 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let AuthorizationManager +module.exports = AuthorizationManager = { + assertClientCanViewProject(client, callback) { + if (callback == null) { + callback = function (error) {} + } + return AuthorizationManager._assertClientHasPrivilegeLevel( + client, + ['readOnly', 'readAndWrite', 'owner'], + callback + ) + }, + + assertClientCanEditProject(client, callback) { + if (callback == null) { + callback = function (error) {} + } + return AuthorizationManager._assertClientHasPrivilegeLevel( + client, + ['readAndWrite', 'owner'], + callback + ) + }, + + _assertClientHasPrivilegeLevel(client, allowedLevels, callback) { + if (callback == null) { + callback = function (error) {} + } + if (Array.from(allowedLevels).includes(client.ol_context.privilege_level)) { + return callback(null) + } else { + return callback(new Error('not authorized')) + } + }, + + assertClientCanViewProjectAndDoc(client, doc_id, callback) { + if (callback == null) { + callback = function (error) {} + } + return AuthorizationManager.assertClientCanViewProject(client, function ( + error + ) { + if (error != null) { + return callback(error) + } + return AuthorizationManager._assertClientCanAccessDoc( + client, + doc_id, + callback + ) + }) + }, + + assertClientCanEditProjectAndDoc(client, doc_id, callback) { + if (callback == null) { + callback = function (error) {} + } + return AuthorizationManager.assertClientCanEditProject(client, function ( + error + ) { + if (error != null) { + return callback(error) + } + return AuthorizationManager._assertClientCanAccessDoc( + client, + doc_id, + callback + ) + }) + }, + + _assertClientCanAccessDoc(client, doc_id, callback) { + if (callback == null) { + callback = function (error) {} + } + if (client.ol_context[`doc:${doc_id}`] === 'allowed') { + return callback(null) + } else { + return callback(new Error('not authorized')) + } + }, + + addAccessToDoc(client, doc_id, callback) { + if (callback == null) { + callback = function (error) {} + } + client.ol_context[`doc:${doc_id}`] = 'allowed' + return callback(null) + }, + + removeAccessToDoc(client, doc_id, callback) { + if (callback == null) { + callback = function (error) {} + } + delete client.ol_context[`doc:${doc_id}`] + return callback(null) + } +} diff --git a/services/real-time/app/js/ChannelManager.js b/services/real-time/app/js/ChannelManager.js new file mode 100644 index 0000000000..09e81cebf5 --- /dev/null +++ b/services/real-time/app/js/ChannelManager.js @@ -0,0 +1,105 @@ +/* eslint-disable + 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 + */ +let ChannelManager +const logger = require('logger-sharelatex') +const metrics = require('metrics-sharelatex') +const settings = require('settings-sharelatex') + +const ClientMap = new Map() // for each redis client, store a Map of subscribed channels (channelname -> subscribe promise) + +// Manage redis pubsub subscriptions for individual projects and docs, ensuring +// that we never subscribe to a channel multiple times. The socket.io side is +// handled by RoomManager. + +module.exports = ChannelManager = { + getClientMapEntry(rclient) { + // return the per-client channel map if it exists, otherwise create and + // return an empty map for the client. + return ( + ClientMap.get(rclient) || ClientMap.set(rclient, new Map()).get(rclient) + ) + }, + + subscribe(rclient, baseChannel, id) { + const clientChannelMap = this.getClientMapEntry(rclient) + const channel = `${baseChannel}:${id}` + const actualSubscribe = function () { + // subscribe is happening in the foreground and it should reject + const p = rclient.subscribe(channel) + p.finally(function () { + if (clientChannelMap.get(channel) === subscribePromise) { + return clientChannelMap.delete(channel) + } + }) + .then(function () { + logger.log({ channel }, 'subscribed to channel') + return metrics.inc(`subscribe.${baseChannel}`) + }) + .catch(function (err) { + logger.error({ channel, err }, 'failed to subscribe to channel') + return metrics.inc(`subscribe.failed.${baseChannel}`) + }) + return p + } + + const pendingActions = clientChannelMap.get(channel) || Promise.resolve() + var subscribePromise = pendingActions.then(actualSubscribe, actualSubscribe) + clientChannelMap.set(channel, subscribePromise) + logger.log({ channel }, 'planned to subscribe to channel') + return subscribePromise + }, + + unsubscribe(rclient, baseChannel, id) { + const clientChannelMap = this.getClientMapEntry(rclient) + const channel = `${baseChannel}:${id}` + const actualUnsubscribe = function () { + // unsubscribe is happening in the background, it should not reject + const p = rclient + .unsubscribe(channel) + .finally(function () { + if (clientChannelMap.get(channel) === unsubscribePromise) { + return clientChannelMap.delete(channel) + } + }) + .then(function () { + logger.log({ channel }, 'unsubscribed from channel') + return metrics.inc(`unsubscribe.${baseChannel}`) + }) + .catch(function (err) { + logger.error({ channel, err }, 'unsubscribed from channel') + return metrics.inc(`unsubscribe.failed.${baseChannel}`) + }) + return p + } + + const pendingActions = clientChannelMap.get(channel) || Promise.resolve() + var unsubscribePromise = pendingActions.then( + actualUnsubscribe, + actualUnsubscribe + ) + clientChannelMap.set(channel, unsubscribePromise) + logger.log({ channel }, 'planned to unsubscribe from channel') + return unsubscribePromise + }, + + publish(rclient, baseChannel, id, data) { + let channel + metrics.summary(`redis.publish.${baseChannel}`, data.length) + if (id === 'all' || !settings.publishOnIndividualChannels) { + channel = baseChannel + } else { + channel = `${baseChannel}:${id}` + } + // we publish on a different client to the subscribe, so we can't + // check for the channel existing here + return rclient.publish(channel, data) + } +} diff --git a/services/real-time/app/js/ConnectedUsersManager.js b/services/real-time/app/js/ConnectedUsersManager.js new file mode 100644 index 0000000000..6770dd5421 --- /dev/null +++ b/services/real-time/app/js/ConnectedUsersManager.js @@ -0,0 +1,194 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const async = require('async') +const Settings = require('settings-sharelatex') +const logger = require('logger-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(Settings.redis.realtime) +const Keys = Settings.redis.realtime.key_schema + +const ONE_HOUR_IN_S = 60 * 60 +const ONE_DAY_IN_S = ONE_HOUR_IN_S * 24 +const FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4 + +const USER_TIMEOUT_IN_S = ONE_HOUR_IN_S / 4 +const REFRESH_TIMEOUT_IN_S = 10 // only show clients which have responded to a refresh request in the last 10 seconds + +module.exports = { + // Use the same method for when a user connects, and when a user sends a cursor + // update. This way we don't care if the connected_user key has expired when + // we receive a cursor update. + updateUserPosition(project_id, client_id, user, cursorData, callback) { + if (callback == null) { + callback = function (err) {} + } + logger.log({ project_id, client_id }, 'marking user as joined or connected') + + const multi = rclient.multi() + + multi.sadd(Keys.clientsInProject({ project_id }), client_id) + multi.expire(Keys.clientsInProject({ project_id }), FOUR_DAYS_IN_S) + + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'last_updated_at', + Date.now() + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'user_id', + user._id + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'first_name', + user.first_name || '' + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'last_name', + user.last_name || '' + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'email', + user.email || '' + ) + + if (cursorData != null) { + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'cursorData', + JSON.stringify(cursorData) + ) + } + multi.expire( + Keys.connectedUser({ project_id, client_id }), + USER_TIMEOUT_IN_S + ) + + return multi.exec(function (err) { + if (err != null) { + logger.err( + { err, project_id, client_id }, + 'problem marking user as connected' + ) + } + return callback(err) + }) + }, + + refreshClient(project_id, client_id, callback) { + if (callback == null) { + callback = function (err) {} + } + logger.log({ project_id, client_id }, 'refreshing connected client') + const multi = rclient.multi() + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'last_updated_at', + Date.now() + ) + multi.expire( + Keys.connectedUser({ project_id, client_id }), + USER_TIMEOUT_IN_S + ) + return multi.exec(function (err) { + if (err != null) { + logger.err( + { err, project_id, client_id }, + 'problem refreshing connected client' + ) + } + return callback(err) + }) + }, + + markUserAsDisconnected(project_id, client_id, callback) { + logger.log({ project_id, client_id }, 'marking user as disconnected') + const multi = rclient.multi() + multi.srem(Keys.clientsInProject({ project_id }), client_id) + multi.expire(Keys.clientsInProject({ project_id }), FOUR_DAYS_IN_S) + multi.del(Keys.connectedUser({ project_id, client_id })) + return multi.exec(callback) + }, + + _getConnectedUser(project_id, client_id, callback) { + return rclient.hgetall( + Keys.connectedUser({ project_id, client_id }), + function (err, result) { + if ( + result == null || + Object.keys(result).length === 0 || + !result.user_id + ) { + result = { + connected: false, + client_id + } + } else { + result.connected = true + result.client_id = client_id + result.client_age = + (Date.now() - parseInt(result.last_updated_at, 10)) / 1000 + if (result.cursorData != null) { + try { + result.cursorData = JSON.parse(result.cursorData) + } catch (e) { + logger.error( + { + err: e, + project_id, + client_id, + cursorData: result.cursorData + }, + 'error parsing cursorData JSON' + ) + return callback(e) + } + } + } + return callback(err, result) + } + ) + }, + + getConnectedUsers(project_id, callback) { + const self = this + return rclient.smembers(Keys.clientsInProject({ project_id }), function ( + err, + results + ) { + if (err != null) { + return callback(err) + } + const jobs = results.map((client_id) => (cb) => + self._getConnectedUser(project_id, client_id, cb) + ) + return async.series(jobs, function (err, users) { + if (users == null) { + users = [] + } + if (err != null) { + return callback(err) + } + users = users.filter( + (user) => + (user != null ? user.connected : undefined) && + (user != null ? user.client_age : undefined) < REFRESH_TIMEOUT_IN_S + ) + return callback(null, users) + }) + }) + } +} diff --git a/services/real-time/app/js/DocumentUpdaterController.js b/services/real-time/app/js/DocumentUpdaterController.js new file mode 100644 index 0000000000..b8dde3b426 --- /dev/null +++ b/services/real-time/app/js/DocumentUpdaterController.js @@ -0,0 +1,208 @@ +/* eslint-disable + camelcase, + no-unused-vars, +*/ +// 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 + * 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 + */ +let DocumentUpdaterController +const logger = require('logger-sharelatex') +const settings = require('settings-sharelatex') +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('metrics-sharelatex') + +const MESSAGE_SIZE_LOG_LIMIT = 1024 * 1024 // 1Mb + +module.exports = DocumentUpdaterController = { + // DocumentUpdaterController is responsible for updates that come via Redis + // Pub/Sub from the document updater. + rclientList: RedisClientManager.createClientList(settings.redis.pubsub), + + listenForUpdatesFromDocumentUpdater(io) { + let i, rclient + logger.log( + { rclients: this.rclientList.length }, + 'listening for applied-ops events' + ) + for (i = 0; i < this.rclientList.length; i++) { + rclient = this.rclientList[i] + rclient.subscribe('applied-ops') + rclient.on('message', function (channel, message) { + metrics.inc('rclient', 0.001) // global event rate metric + if (settings.debugEvents > 0) { + EventLogger.debugEvent(channel, message) + } + return DocumentUpdaterController._processMessageFromDocumentUpdater( + io, + channel, + message + ) + }) + } + // create metrics for each redis instance only when we have multiple redis clients + if (this.rclientList.length > 1) { + for (i = 0; i < this.rclientList.length; i++) { + rclient = this.rclientList[i] + ;(( + i // per client event rate metric + ) => rclient.on('message', () => metrics.inc(`rclient-${i}`, 0.001)))(i) + } + } + return this.handleRoomUpdates(this.rclientList) + }, + + handleRoomUpdates(rclientSubList) { + const roomEvents = RoomManager.eventSource() + roomEvents.on('doc-active', function (doc_id) { + const subscribePromises = Array.from(rclientSubList).map((rclient) => + ChannelManager.subscribe(rclient, 'applied-ops', doc_id) + ) + return RoomManager.emitOnCompletion( + subscribePromises, + `doc-subscribed-${doc_id}` + ) + }) + return roomEvents.on('doc-empty', (doc_id) => + Array.from(rclientSubList).map((rclient) => + ChannelManager.unsubscribe(rclient, 'applied-ops', doc_id) + ) + ) + }, + + _processMessageFromDocumentUpdater(io, channel, message) { + return SafeJsonParse.parse(message, function (error, message) { + if (error != null) { + logger.error({ err: error, channel }, 'error parsing JSON') + return + } + if (message.op != null) { + if (message._id != null && settings.checkEventOrder) { + const status = EventLogger.checkEventOrder( + 'applied-ops', + message._id, + message + ) + if (status === 'duplicate') { + return // skip duplicate events + } + } + return DocumentUpdaterController._applyUpdateFromDocumentUpdater( + io, + message.doc_id, + message.op + ) + } else if (message.error != null) { + return DocumentUpdaterController._processErrorFromDocumentUpdater( + io, + message.doc_id, + message.error, + message + ) + } else if (message.health_check != null) { + logger.debug( + { message }, + 'got health check message in applied ops channel' + ) + return HealthCheckManager.check(channel, message.key) + } + }) + }, + + _applyUpdateFromDocumentUpdater(io, doc_id, update) { + let client + const clientList = io.sockets.clients(doc_id) + // avoid unnecessary work if no clients are connected + if (clientList.length === 0) { + return + } + // send updates to clients + logger.log( + { + doc_id, + version: update.v, + source: update.meta != null ? update.meta.source : undefined, + socketIoClients: (() => { + const result = [] + for (client of Array.from(clientList)) { + result.push(client.id) + } + return result + })() + }, + 'distributing updates to clients' + ) + const seen = {} + // send messages only to unique clients (due to duplicate entries in io.sockets.clients) + for (client of Array.from(clientList)) { + if (!seen[client.id]) { + seen[client.id] = true + if (client.publicId === update.meta.source) { + logger.log( + { + doc_id, + version: update.v, + source: update.meta != null ? update.meta.source : undefined + }, + 'distributing update to sender' + ) + client.emit('otUpdateApplied', { v: update.v, doc: update.doc }) + } else if (!update.dup) { + // Duplicate ops should just be sent back to sending client for acknowledgement + logger.log( + { + doc_id, + version: update.v, + source: update.meta != null ? update.meta.source : undefined, + client_id: client.id + }, + 'distributing update to collaborator' + ) + client.emit('otUpdateApplied', update) + } + } + } + if (Object.keys(seen).length < clientList.length) { + metrics.inc('socket-io.duplicate-clients', 0.1) + return logger.log( + { + doc_id, + socketIoClients: (() => { + const result1 = [] + for (client of Array.from(clientList)) { + result1.push(client.id) + } + return result1 + })() + }, + 'discarded duplicate clients' + ) + } + }, + + _processErrorFromDocumentUpdater(io, doc_id, error, message) { + return (() => { + const result = [] + for (const client of Array.from(io.sockets.clients(doc_id))) { + logger.warn( + { err: error, doc_id, client_id: client.id }, + 'error from document updater, disconnecting client' + ) + client.emit('otUpdateError', error, message) + result.push(client.disconnect()) + } + return result + })() + } +} diff --git a/services/real-time/app/js/DocumentUpdaterManager.js b/services/real-time/app/js/DocumentUpdaterManager.js new file mode 100644 index 0000000000..bafc81ed14 --- /dev/null +++ b/services/real-time/app/js/DocumentUpdaterManager.js @@ -0,0 +1,169 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let DocumentUpdaterManager +const request = require('request') +const _ = require('underscore') +const logger = require('logger-sharelatex') +const settings = require('settings-sharelatex') +const metrics = require('metrics-sharelatex') + +const rclient = require('redis-sharelatex').createClient( + settings.redis.documentupdater +) +const Keys = settings.redis.documentupdater.key_schema + +module.exports = DocumentUpdaterManager = { + getDocument(project_id, doc_id, fromVersion, callback) { + if (callback == null) { + callback = function (error, exists, doclines, version) {} + } + const timer = new metrics.Timer('get-document') + const url = `${settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}` + logger.log( + { project_id, doc_id, fromVersion }, + 'getting doc from document updater' + ) + return request.get(url, function (err, res, body) { + timer.done() + if (err != null) { + logger.error( + { err, url, project_id, doc_id }, + 'error getting doc from doc updater' + ) + return callback(err) + } + if (res.statusCode >= 200 && res.statusCode < 300) { + logger.log( + { project_id, doc_id }, + 'got doc from document document updater' + ) + try { + body = JSON.parse(body) + } catch (error) { + return callback(error) + } + return callback( + null, + body != null ? body.lines : undefined, + body != null ? body.version : undefined, + body != null ? body.ranges : undefined, + body != null ? body.ops : undefined + ) + } else if ([404, 422].includes(res.statusCode)) { + err = new Error('doc updater could not load requested ops') + err.statusCode = res.statusCode + logger.warn( + { err, project_id, doc_id, url, fromVersion }, + 'doc updater could not load requested ops' + ) + return callback(err) + } else { + err = new Error( + `doc updater returned a non-success status code: ${res.statusCode}` + ) + err.statusCode = res.statusCode + logger.error( + { err, project_id, doc_id, url }, + `doc updater returned a non-success status code: ${res.statusCode}` + ) + return callback(err) + } + }) + }, + + flushProjectToMongoAndDelete(project_id, callback) { + // this method is called when the last connected user leaves the project + if (callback == null) { + callback = function () {} + } + logger.log({ project_id }, 'deleting project from document updater') + const timer = new metrics.Timer('delete.mongo.project') + // flush the project in the background when all users have left + const url = + `${settings.apis.documentupdater.url}/project/${project_id}?background=true` + + (settings.shutDownInProgress ? '&shutdown=true' : '') + return request.del(url, function (err, res, body) { + timer.done() + if (err != null) { + logger.error( + { err, project_id }, + 'error deleting project from document updater' + ) + return callback(err) + } else if (res.statusCode >= 200 && res.statusCode < 300) { + logger.log({ project_id }, 'deleted project from document updater') + return callback(null) + } else { + err = new Error( + `document updater returned a failure status code: ${res.statusCode}` + ) + err.statusCode = res.statusCode + logger.error( + { err, project_id }, + `document updater returned failure status code: ${res.statusCode}` + ) + return callback(err) + } + }) + }, + + queueChange(project_id, doc_id, change, callback) { + let error + if (callback == null) { + callback = function () {} + } + const allowedKeys = [ + 'doc', + 'op', + 'v', + 'dupIfSource', + 'meta', + 'lastV', + 'hash' + ] + change = _.pick(change, allowedKeys) + const jsonChange = JSON.stringify(change) + if (jsonChange.indexOf('\u0000') !== -1) { + // memory corruption check + error = new Error('null bytes found in op') + logger.error( + { err: error, project_id, doc_id, jsonChange }, + error.message + ) + return callback(error) + } + + const updateSize = jsonChange.length + if (updateSize > settings.maxUpdateSize) { + error = new Error('update is too large') + error.updateSize = updateSize + return callback(error) + } + + // record metric for each update added to queue + metrics.summary('redis.pendingUpdates', updateSize, { status: 'push' }) + + const doc_key = `${project_id}:${doc_id}` + // Push onto pendingUpdates for doc_id first, because once the doc updater + // gets an entry on pending-updates-list, it starts processing. + return rclient.rpush(Keys.pendingUpdates({ doc_id }), jsonChange, function ( + error + ) { + if (error != null) { + return callback(error) + } + return rclient.rpush('pending-updates-list', doc_key, callback) + }) + } +} diff --git a/services/real-time/app/js/DrainManager.js b/services/real-time/app/js/DrainManager.js new file mode 100644 index 0000000000..b8c08356bb --- /dev/null +++ b/services/real-time/app/js/DrainManager.js @@ -0,0 +1,63 @@ +/* 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 + */ +let DrainManager +const logger = require('logger-sharelatex') + +module.exports = DrainManager = { + startDrainTimeWindow(io, minsToDrain) { + const drainPerMin = io.sockets.clients().length / minsToDrain + return DrainManager.startDrain(io, Math.max(drainPerMin / 60, 4)) + }, // enforce minimum drain rate + + startDrain(io, rate) { + // Clear out any old interval + let pollingInterval + clearInterval(this.interval) + logger.log({ rate }, 'starting drain') + if (rate === 0) { + return + } else if (rate < 1) { + // allow lower drain rates + // e.g. rate=0.1 will drain one client every 10 seconds + pollingInterval = 1000 / rate + rate = 1 + } else { + pollingInterval = 1000 + } + return (this.interval = setInterval(() => { + return this.reconnectNClients(io, rate) + }, pollingInterval)) + }, + + RECONNECTED_CLIENTS: {}, + reconnectNClients(io, N) { + let drainedCount = 0 + for (const client of Array.from(io.sockets.clients())) { + if (!this.RECONNECTED_CLIENTS[client.id]) { + this.RECONNECTED_CLIENTS[client.id] = true + logger.log( + { client_id: client.id }, + 'Asking client to reconnect gracefully' + ) + client.emit('reconnectGracefully') + drainedCount++ + } + const haveDrainedNClients = drainedCount === N + if (haveDrainedNClients) { + break + } + } + if (drainedCount < N) { + return logger.log('All clients have been told to reconnectGracefully') + } + } +} diff --git a/services/real-time/app/js/Errors.js b/services/real-time/app/js/Errors.js new file mode 100644 index 0000000000..8bfe3763b0 --- /dev/null +++ b/services/real-time/app/js/Errors.js @@ -0,0 +1,17 @@ +/* eslint-disable + no-proto, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +let Errors +var CodedError = function (message, code) { + const error = new Error(message) + error.name = 'CodedError' + error.code = code + error.__proto__ = CodedError.prototype + return error +} +CodedError.prototype.__proto__ = Error.prototype + +module.exports = Errors = { CodedError } diff --git a/services/real-time/app/js/EventLogger.js b/services/real-time/app/js/EventLogger.js new file mode 100644 index 0000000000..1133ebdaf8 --- /dev/null +++ b/services/real-time/app/js/EventLogger.js @@ -0,0 +1,100 @@ +/* eslint-disable + camelcase, +*/ +// 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 + * 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 + */ +let EventLogger +const logger = require('logger-sharelatex') +const metrics = require('metrics-sharelatex') +const settings = require('settings-sharelatex') + +// keep track of message counters to detect duplicate and out of order events +// messsage ids have the format "UNIQUEHOSTKEY-COUNTER" + +const EVENT_LOG_COUNTER = {} +const EVENT_LOG_TIMESTAMP = {} +let EVENT_LAST_CLEAN_TIMESTAMP = 0 + +// counter for debug logs +let COUNTER = 0 + +module.exports = EventLogger = { + MAX_STALE_TIME_IN_MS: 3600 * 1000, + + debugEvent(channel, message) { + if (settings.debugEvents > 0) { + logger.log({ channel, message, counter: COUNTER++ }, 'logging event') + return settings.debugEvents-- + } + }, + + checkEventOrder(channel, message_id, message) { + let result + if (typeof message_id !== 'string') { + return + } + if (!(result = message_id.match(/^(.*)-(\d+)$/))) { + return + } + const key = result[1] + const count = parseInt(result[2], 0) + if (!(count >= 0)) { + // ignore checks if counter is not present + return + } + // store the last count in a hash for each host + const previous = EventLogger._storeEventCount(key, count) + if (previous == null || count === previous + 1) { + metrics.inc(`event.${channel}.valid`, 0.001) // downsample high rate docupdater events + return // order is ok + } + if (count === previous) { + metrics.inc(`event.${channel}.duplicate`) + logger.warn({ channel, message_id }, 'duplicate event') + return 'duplicate' + } else { + metrics.inc(`event.${channel}.out-of-order`) + logger.warn( + { channel, message_id, key, previous, count }, + 'out of order event' + ) + return 'out-of-order' + } + }, + + _storeEventCount(key, count) { + const previous = EVENT_LOG_COUNTER[key] + const now = Date.now() + EVENT_LOG_COUNTER[key] = count + EVENT_LOG_TIMESTAMP[key] = now + // periodically remove old counts + if (now - EVENT_LAST_CLEAN_TIMESTAMP > EventLogger.MAX_STALE_TIME_IN_MS) { + EventLogger._cleanEventStream(now) + EVENT_LAST_CLEAN_TIMESTAMP = now + } + return previous + }, + + _cleanEventStream(now) { + return (() => { + const result = [] + for (const key in EVENT_LOG_TIMESTAMP) { + const timestamp = EVENT_LOG_TIMESTAMP[key] + if (now - timestamp > EventLogger.MAX_STALE_TIME_IN_MS) { + delete EVENT_LOG_COUNTER[key] + result.push(delete EVENT_LOG_TIMESTAMP[key]) + } else { + result.push(undefined) + } + } + return result + })() + } +} diff --git a/services/real-time/app/js/HealthCheckManager.js b/services/real-time/app/js/HealthCheckManager.js new file mode 100644 index 0000000000..4704aa5e88 --- /dev/null +++ b/services/real-time/app/js/HealthCheckManager.js @@ -0,0 +1,93 @@ +/* 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let HealthCheckManager +const metrics = require('metrics-sharelatex') +const logger = require('logger-sharelatex') + +const os = require('os') +const HOST = os.hostname() +const PID = process.pid +let COUNT = 0 + +const CHANNEL_MANAGER = {} // hash of event checkers by channel name +const CHANNEL_ERROR = {} // error status by channel name + +module.exports = HealthCheckManager = 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) { + // unique event string + this.channel = channel + if (timeout == null) { + timeout = 1000 + } + this.id = `host=${HOST}:pid=${PID}:count=${COUNT++}` + // count of number of times the event is received + this.count = 0 + // after a timeout check the status of the count + this.handler = setTimeout(() => { + return this.setStatus() + }, timeout) + // use a timer to record the latency of the channel + this.timer = new metrics.Timer(`event.${this.channel}.latency`) + // keep a record of these objects to dispatch on + CHANNEL_MANAGER[this.channel] = this + } + + processEvent(id) { + // if this is our event record it + if (id === this.id) { + this.count++ + if (this.timer != null) { + this.timer.done() + } + return (this.timer = null) // only time the latency of the first event + } + } + + setStatus() { + // if we saw the event anything other than a single time that is an error + if (this.count !== 1) { + logger.err( + { channel: this.channel, count: this.count, id: this.id }, + 'redis channel health check error' + ) + } + const error = this.count !== 1 + return (CHANNEL_ERROR[this.channel] = error) + } + + // class methods + static check(channel, id) { + // dispatch event to manager for channel + return CHANNEL_MANAGER[channel] != null + ? CHANNEL_MANAGER[channel].processEvent(id) + : undefined + } + + static status() { + // return status of all channels for logging + return CHANNEL_ERROR + } + + static isFailing() { + // check if any channel status is bad + for (const channel in CHANNEL_ERROR) { + const error = CHANNEL_ERROR[channel] + if (error === true) { + return true + } + } + return false + } +} diff --git a/services/real-time/app/js/HttpApiController.js b/services/real-time/app/js/HttpApiController.js new file mode 100644 index 0000000000..a512961797 --- /dev/null +++ b/services/real-time/app/js/HttpApiController.js @@ -0,0 +1,62 @@ +/* eslint-disable + camelcase, + no-unused-vars, +*/ +// 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 + */ +let HttpApiController +const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') +const DrainManager = require('./DrainManager') +const logger = require('logger-sharelatex') + +module.exports = HttpApiController = { + sendMessage(req, res, next) { + logger.log({ message: req.params.message }, 'sending message') + if (Array.isArray(req.body)) { + for (const payload of Array.from(req.body)) { + WebsocketLoadBalancer.emitToRoom( + req.params.project_id, + req.params.message, + payload + ) + } + } else { + WebsocketLoadBalancer.emitToRoom( + req.params.project_id, + req.params.message, + req.body + ) + } + return res.send(204) + }, // No content + + startDrain(req, res, next) { + const io = req.app.get('io') + let rate = req.query.rate || '4' + rate = parseFloat(rate) || 0 + logger.log({ rate }, 'setting client drain rate') + DrainManager.startDrain(io, rate) + return res.send(204) + }, + + disconnectClient(req, res, next) { + const io = req.app.get('io') + const { client_id } = req.params + const client = io.sockets.sockets[client_id] + + if (!client) { + logger.info({ client_id }, 'api: client already disconnected') + res.sendStatus(404) + return + } + logger.warn({ client_id }, 'api: requesting client disconnect') + client.on('disconnect', () => res.sendStatus(204)) + return client.disconnect() + } +} diff --git a/services/real-time/app/js/HttpController.js b/services/real-time/app/js/HttpController.js new file mode 100644 index 0000000000..deabf5876d --- /dev/null +++ b/services/real-time/app/js/HttpController.js @@ -0,0 +1,87 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let HttpController +const async = require('async') + +module.exports = 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 ShareLaTeX, and is only used for getting stats about connected clients, + // and for checking internal state in acceptance tests. The acceptances tests + // should provide appropriate coverage. + _getConnectedClientView(ioClient, callback) { + if (callback == null) { + callback = function (error, client) {} + } + const client_id = ioClient.id + const { + project_id, + user_id, + first_name, + last_name, + email, + connected_time + } = ioClient.ol_context + const client = { + client_id, + project_id, + user_id, + first_name, + last_name, + email, + connected_time + } + client.rooms = [] + for (const name in ioClient.manager.roomClients[client_id]) { + const joined = ioClient.manager.roomClients[client_id][name] + if (joined && name !== '') { + client.rooms.push(name.replace(/^\//, '')) // Remove leading / + } + } + return callback(null, client) + }, + + getConnectedClients(req, res, next) { + const io = req.app.get('io') + const ioClients = io.sockets.clients() + return async.map( + ioClients, + HttpController._getConnectedClientView, + function (error, clients) { + if (error != null) { + return next(error) + } + return res.json(clients) + } + ) + }, + + getConnectedClient(req, res, next) { + const { client_id } = req.params + const io = req.app.get('io') + const ioClient = io.sockets.sockets[client_id] + if (!ioClient) { + res.sendStatus(404) + return + } + return HttpController._getConnectedClientView(ioClient, function ( + error, + client + ) { + if (error != null) { + return next(error) + } + return res.json(client) + }) + } +} diff --git a/services/real-time/app/js/RedisClientManager.js b/services/real-time/app/js/RedisClientManager.js new file mode 100644 index 0000000000..b43262aeda --- /dev/null +++ b/services/real-time/app/js/RedisClientManager.js @@ -0,0 +1,40 @@ +/* eslint-disable + no-unused-vars, +*/ +// 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 + * 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 + */ +let RedisClientManager +const redis = require('redis-sharelatex') +const logger = require('logger-sharelatex') + +module.exports = RedisClientManager = { + createClientList(...configs) { + // create a dynamic list of redis clients, excluding any configurations which are not defined + const clientList = (() => { + const result = [] + for (const x of Array.from(configs)) { + if (x != null) { + const redisType = + x.cluster != null + ? 'cluster' + : x.sentinels != null + ? 'sentinel' + : x.host != null + ? 'single' + : 'unknown' + logger.log({ redis: redisType }, 'creating redis client') + result.push(redis.createClient(x)) + } + } + return result + })() + return clientList + } +} diff --git a/services/real-time/app/js/RoomManager.js b/services/real-time/app/js/RoomManager.js new file mode 100644 index 0000000000..8dd34e9340 --- /dev/null +++ b/services/real-time/app/js/RoomManager.js @@ -0,0 +1,189 @@ +/* eslint-disable + camelcase, + no-unused-vars, +*/ +// 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 + */ +let RoomManager +const logger = require('logger-sharelatex') +const metrics = require('metrics-sharelatex') +const { EventEmitter } = require('events') + +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 + +// Manage socket.io rooms for individual projects and docs +// +// The first time someone joins a project or doc we emit a 'project-active' or +// 'doc-active' event. +// +// When the last person leaves a project or doc, we emit 'project-empty' or +// 'doc-empty' event. +// +// The pubsub side is handled by ChannelManager + +module.exports = RoomManager = { + joinProject(client, project_id, callback) { + if (callback == null) { + callback = function () {} + } + return this.joinEntity(client, 'project', project_id, callback) + }, + + joinDoc(client, doc_id, callback) { + if (callback == null) { + callback = function () {} + } + return this.joinEntity(client, 'doc', doc_id, callback) + }, + + leaveDoc(client, doc_id) { + return this.leaveEntity(client, 'doc', doc_id) + }, + + leaveProjectAndDocs(client) { + // what rooms is this client in? we need to leave them all. socket.io + // will cause us to leave the rooms, so we only need to manage our + // channel subscriptions... but it will be safer if we leave them + // explicitly, and then socket.io will just regard this as a client that + // has not joined any rooms and do a final disconnection. + const roomsToLeave = this._roomsClientIsIn(client) + logger.log({ client: client.id, roomsToLeave }, 'client leaving project') + return (() => { + const result = [] + for (const id of Array.from(roomsToLeave)) { + const entity = IdMap.get(id) + result.push(this.leaveEntity(client, entity, id)) + } + return result + })() + }, + + emitOnCompletion(promiseList, eventName) { + return Promise.all(promiseList) + .then(() => RoomEvents.emit(eventName)) + .catch((err) => RoomEvents.emit(eventName, err)) + }, + + eventSource() { + return RoomEvents + }, + + joinEntity(client, entity, id, callback) { + const beforeCount = this._clientsInRoom(client, id) + // client joins room immediately but joinDoc request does not complete + // until room is subscribed + client.join(id) + // is this a new room? if so, subscribe + if (beforeCount === 0) { + logger.log({ entity, id }, 'room is now active') + RoomEvents.once(`${entity}-subscribed-${id}`, function (err) { + // only allow the client to join when all the relevant channels have subscribed + logger.log( + { client: client.id, entity, id, beforeCount }, + 'client joined new room and subscribed to channel' + ) + return callback(err) + }) + RoomEvents.emit(`${entity}-active`, id) + IdMap.set(id, entity) + // keep track of the number of listeners + return metrics.gauge('room-listeners', RoomEvents.eventNames().length) + } else { + logger.log( + { client: client.id, entity, id, beforeCount }, + 'client joined existing room' + ) + client.join(id) + return callback() + } + }, + + leaveEntity(client, entity, id) { + // Ignore any requests to leave when the client is not actually in the + // room. This can happen if the client sends spurious leaveDoc requests + // for old docs after a reconnection. + // This can now happen all the time, as we skip the join for clients that + // disconnect before joinProject/joinDoc completed. + if (!this._clientAlreadyInRoom(client, id)) { + logger.log( + { client: client.id, entity, id }, + 'ignoring request from client to leave room it is not in' + ) + return + } + client.leave(id) + const afterCount = this._clientsInRoom(client, id) + logger.log( + { client: client.id, entity, id, afterCount }, + 'client left room' + ) + // is the room now empty? if so, unsubscribe + if (entity == null) { + logger.error({ entity: id }, 'unknown entity when leaving with id') + return + } + if (afterCount === 0) { + logger.log({ entity, id }, 'room is now empty') + RoomEvents.emit(`${entity}-empty`, id) + IdMap.delete(id) + return metrics.gauge('room-listeners', RoomEvents.eventNames().length) + } + }, + + // internal functions below, these access socket.io rooms data directly and + // will need updating for socket.io v2 + + _clientsInRoom(client, room) { + const nsp = client.namespace.name + const name = nsp + '/' + room + return ( + __guard__( + client.manager != null ? client.manager.rooms : undefined, + (x) => x[name] + ) || [] + ).length + }, + + _roomsClientIsIn(client) { + const roomList = (() => { + const result = [] + for (const fullRoomPath in client.manager.roomClients != null + ? client.manager.roomClients[client.id] + : undefined) { + // strip socket.io prefix from room to get original id + if (fullRoomPath !== '') { + const [prefix, room] = Array.from(fullRoomPath.split('/', 2)) + result.push(room) + } + } + return result + })() + return roomList + }, + + _clientAlreadyInRoom(client, room) { + const nsp = client.namespace.name + const name = nsp + '/' + room + return __guard__( + client.manager.roomClients != null + ? client.manager.roomClients[client.id] + : undefined, + (x) => x[name] + ) + } +} +function __guard__(value, transform) { + return typeof value !== 'undefined' && value !== null + ? transform(value) + : undefined +} diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js new file mode 100644 index 0000000000..0e19c46bc0 --- /dev/null +++ b/services/real-time/app/js/Router.js @@ -0,0 +1,402 @@ +/* eslint-disable + camelcase, + handle-callback-err, + standard/no-callback-literal, +*/ +// 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__ + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Router +const metrics = require('metrics-sharelatex') +const logger = require('logger-sharelatex') +const settings = require('settings-sharelatex') +const WebsocketController = require('./WebsocketController') +const HttpController = require('./HttpController') +const HttpApiController = require('./HttpApiController') +const bodyParser = require('body-parser') +const base64id = require('base64id') + +const basicAuth = require('basic-auth-connect') +const httpAuth = basicAuth(function (user, pass) { + const isValid = + user === settings.internal.realTime.user && + pass === settings.internal.realTime.pass + if (!isValid) { + logger.err({ user, pass }, 'invalid login details') + } + return isValid +}) + +module.exports = Router = { + _handleError(callback, error, client, method, attrs) { + if (callback == null) { + callback = function (error) {} + } + if (attrs == null) { + attrs = {} + } + for (const key of ['project_id', 'doc_id', 'user_id']) { + attrs[key] = client.ol_context[key] + } + attrs.client_id = client.id + attrs.err = error + if (error.name === 'CodedError') { + logger.warn(attrs, error.message, { code: error.code }) + return callback({ message: error.message, code: error.code }) + } + if (error.message === 'unexpected arguments') { + // the payload might be very large, put it on level info + logger.log(attrs, 'unexpected arguments') + metrics.inc('unexpected-arguments', 1, { status: method }) + return callback({ message: error.message }) + } + if ( + [ + 'not authorized', + 'doc updater could not load requested ops', + 'no project_id found on client' + ].includes(error.message) + ) { + logger.warn(attrs, error.message) + return callback({ message: error.message }) + } else { + logger.error(attrs, `server side error in ${method}`) + // Don't return raw error to prevent leaking server side info + return callback({ message: 'Something went wrong in real-time service' }) + } + }, + + _handleInvalidArguments(client, method, args) { + const error = new Error('unexpected arguments') + let callback = args[args.length - 1] + if (typeof callback !== 'function') { + callback = function () {} + } + const attrs = { arguments: args } + return Router._handleError(callback, error, client, method, attrs) + }, + + configure(app, io, session) { + app.set('io', io) + app.get('/clients', HttpController.getConnectedClients) + app.get('/clients/:client_id', HttpController.getConnectedClient) + + app.post( + '/project/:project_id/message/:message', + httpAuth, + bodyParser.json({ limit: '5mb' }), + HttpApiController.sendMessage + ) + + app.post('/drain', httpAuth, HttpApiController.startDrain) + app.post( + '/client/:client_id/disconnect', + httpAuth, + HttpApiController.disconnectClient + ) + + return session.on('connection', function (error, client, session) { + // init client context, we may access it in Router._handleError before + // setting any values + let user + client.ol_context = {} + + if (client != null) { + client.on('error', function (err) { + logger.err({ clientErr: err }, 'socket.io client error') + if (client.connected) { + client.emit('reconnectGracefully') + return client.disconnect() + } + }) + } + + if (settings.shutDownInProgress) { + client.emit('connectionRejected', { message: 'retry' }) + client.disconnect() + return + } + + if ( + client != null && + __guard__(error != null ? error.message : undefined, (x) => + x.match(/could not look up session by key/) + ) + ) { + logger.warn( + { err: error, client: client != null, session: session != null }, + 'invalid session' + ) + // tell the client to reauthenticate if it has an invalid session key + client.emit('connectionRejected', { message: 'invalid session' }) + client.disconnect() + return + } + + if (error != null) { + logger.err( + { err: error, client: client != null, session: session != null }, + 'error when client connected' + ) + if (client != null) { + client.emit('connectionRejected', { message: 'error' }) + } + if (client != null) { + client.disconnect() + } + return + } + + // send positive confirmation that the client has a valid connection + client.publicId = 'P.' + base64id.generateId() + client.emit('connectionAccepted', null, client.publicId) + + metrics.inc('socket-io.connection') + metrics.gauge( + 'socket-io.clients', + __guard__(io.sockets.clients(), (x1) => x1.length) + ) + + logger.log({ session, client_id: client.id }, 'client connected') + + if ( + __guard__( + session != null ? session.passport : undefined, + (x2) => x2.user + ) != null + ) { + ;({ user } = session.passport) + } else if ((session != null ? session.user : undefined) != null) { + ;({ user } = session) + } else { + user = { _id: 'anonymous-user' } + } + + client.on('joinProject', function (data, callback) { + if (data == null) { + data = {} + } + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'joinProject', + arguments + ) + } + + if (data.anonymousAccessToken) { + user.anonymousAccessToken = data.anonymousAccessToken + } + return WebsocketController.joinProject( + client, + user, + data.project_id, + function (err, ...args) { + if (err != null) { + return Router._handleError(callback, err, client, 'joinProject', { + project_id: data.project_id, + user_id: user != null ? user.id : undefined + }) + } else { + return callback(null, ...Array.from(args)) + } + } + ) + }) + + client.on('disconnect', function () { + metrics.inc('socket-io.disconnect') + metrics.gauge( + 'socket-io.clients', + __guard__(io.sockets.clients(), (x3) => x3.length) - 1 + ) + + return WebsocketController.leaveProject(io, client, function (err) { + if (err != null) { + return Router._handleError( + function () {}, + err, + client, + 'leaveProject' + ) + } + }) + }) + + // Variadic. The possible arguments: + // doc_id, callback + // doc_id, fromVersion, callback + // doc_id, options, callback + // doc_id, fromVersion, options, callback + client.on('joinDoc', function (doc_id, fromVersion, options, callback) { + if (typeof fromVersion === 'function' && !options) { + callback = fromVersion + fromVersion = -1 + options = {} + } else if ( + typeof fromVersion === 'number' && + typeof options === 'function' + ) { + callback = options + options = {} + } else if ( + typeof fromVersion === 'object' && + typeof options === 'function' + ) { + callback = options + options = fromVersion + fromVersion = -1 + } else if ( + typeof fromVersion === 'number' && + typeof options === 'object' && + typeof callback === 'function' + ) { + // Called with 4 args, things are as expected + } else { + return Router._handleInvalidArguments(client, 'joinDoc', arguments) + } + + return WebsocketController.joinDoc( + client, + doc_id, + fromVersion, + options, + function (err, ...args) { + if (err != null) { + return Router._handleError(callback, err, client, 'joinDoc', { + doc_id, + fromVersion + }) + } else { + return callback(null, ...Array.from(args)) + } + } + ) + }) + + client.on('leaveDoc', function (doc_id, callback) { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments(client, 'leaveDoc', arguments) + } + + return WebsocketController.leaveDoc(client, doc_id, function ( + err, + ...args + ) { + if (err != null) { + return Router._handleError(callback, err, client, 'leaveDoc') + } else { + return callback(null, ...Array.from(args)) + } + }) + }) + + client.on('clientTracking.getConnectedUsers', function (callback) { + if (callback == null) { + callback = function (error, users) {} + } + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'clientTracking.getConnectedUsers', + arguments + ) + } + + return WebsocketController.getConnectedUsers(client, function ( + err, + users + ) { + if (err != null) { + return Router._handleError( + callback, + err, + client, + 'clientTracking.getConnectedUsers' + ) + } else { + return callback(null, users) + } + }) + }) + + client.on('clientTracking.updatePosition', function ( + cursorData, + callback + ) { + if (callback == null) { + callback = function (error) {} + } + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'clientTracking.updatePosition', + arguments + ) + } + + return WebsocketController.updateClientPosition( + client, + cursorData, + function (err) { + if (err != null) { + return Router._handleError( + callback, + err, + client, + 'clientTracking.updatePosition' + ) + } else { + return callback() + } + } + ) + }) + + return client.on('applyOtUpdate', function (doc_id, update, callback) { + if (callback == null) { + callback = function (error) {} + } + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'applyOtUpdate', + arguments + ) + } + + return WebsocketController.applyOtUpdate( + client, + doc_id, + update, + function (err) { + if (err != null) { + return Router._handleError( + callback, + err, + client, + 'applyOtUpdate', + { doc_id, update } + ) + } else { + return callback() + } + } + ) + }) + }) + } +} + +function __guard__(value, transform) { + return typeof value !== 'undefined' && value !== null + ? transform(value) + : undefined +} diff --git a/services/real-time/app/js/SafeJsonParse.js b/services/real-time/app/js/SafeJsonParse.js new file mode 100644 index 0000000000..6e2e287853 --- /dev/null +++ b/services/real-time/app/js/SafeJsonParse.js @@ -0,0 +1,35 @@ +/* eslint-disable + handle-callback-err, +*/ +// 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const Settings = require('settings-sharelatex') +const logger = require('logger-sharelatex') + +module.exports = { + parse(data, callback) { + let parsed + if (callback == null) { + callback = function (error, parsed) {} + } + if (data.length > Settings.maxUpdateSize) { + logger.error( + { head: data.slice(0, 1024), length: data.length }, + 'data too large to parse' + ) + return callback(new Error('data too large to parse')) + } + try { + parsed = JSON.parse(data) + } catch (e) { + return callback(e) + } + return callback(null, parsed) + } +} diff --git a/services/real-time/app/js/SessionSockets.js b/services/real-time/app/js/SessionSockets.js new file mode 100644 index 0000000000..b01920dfa7 --- /dev/null +++ b/services/real-time/app/js/SessionSockets.js @@ -0,0 +1,37 @@ +// TODO: This file was created by bulk-decaffeinate. +// Sanity-check the conversion and remove this comment. +/* + * 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('events') + +module.exports = function (io, sessionStore, cookieParser, cookieName) { + const missingSessionError = new Error('could not look up session by key') + + const sessionSockets = new EventEmitter() + const next = (error, socket, session) => + sessionSockets.emit('connection', error, socket, session) + + io.on('connection', function (socket) { + const req = socket.handshake + return cookieParser(req, {}, function () { + const sessionId = req.signedCookies && req.signedCookies[cookieName] + if (!sessionId) { + return next(missingSessionError, socket) + } + return sessionStore.get(sessionId, function (error, session) { + if (error) { + return next(error, socket) + } + if (!session) { + return next(missingSessionError, socket) + } + return next(null, socket, session) + }) + }) + }) + + return sessionSockets +} diff --git a/services/real-time/app/js/WebApiManager.js b/services/real-time/app/js/WebApiManager.js new file mode 100644 index 0000000000..266135333a --- /dev/null +++ b/services/real-time/app/js/WebApiManager.js @@ -0,0 +1,86 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let WebApiManager +const request = require('request') +const settings = require('settings-sharelatex') +const logger = require('logger-sharelatex') +const { CodedError } = require('./Errors') + +module.exports = WebApiManager = { + joinProject(project_id, user, callback) { + if (callback == null) { + callback = function (error, project, privilegeLevel, isRestrictedUser) {} + } + const user_id = user._id + logger.log({ project_id, user_id }, 'sending join project request to web') + const url = `${settings.apis.web.url}/project/${project_id}/join` + const headers = {} + if (user.anonymousAccessToken != null) { + headers['x-sl-anonymous-access-token'] = user.anonymousAccessToken + } + return request.post( + { + url, + qs: { user_id }, + auth: { + user: settings.apis.web.user, + pass: settings.apis.web.pass, + sendImmediately: true + }, + json: true, + jar: false, + headers + }, + function (error, response, data) { + let err + if (error != null) { + return callback(error) + } + if (response.statusCode >= 200 && response.statusCode < 300) { + if ( + data == null || + (data != null ? data.project : undefined) == null + ) { + err = new Error('no data returned from joinProject request') + logger.error( + { err, project_id, user_id }, + 'error accessing web api' + ) + return callback(err) + } + return callback( + null, + data.project, + data.privilegeLevel, + data.isRestrictedUser + ) + } else if (response.statusCode === 429) { + logger.log(project_id, user_id, 'rate-limit hit when joining project') + return callback( + new CodedError( + 'rate-limit hit when joining project', + 'TooManyRequests' + ) + ) + } else { + err = new Error( + `non-success status code from web: ${response.statusCode}` + ) + logger.error({ err, project_id, user_id }, 'error accessing web api') + return callback(err) + } + } + ) + } +} diff --git a/services/real-time/app/js/WebsocketController.js b/services/real-time/app/js/WebsocketController.js new file mode 100644 index 0000000000..92632cc154 --- /dev/null +++ b/services/real-time/app/js/WebsocketController.js @@ -0,0 +1,608 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-unused-vars, +*/ +// 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__ + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let WebsocketController +const logger = require('logger-sharelatex') +const metrics = require('metrics-sharelatex') +const settings = require('settings-sharelatex') +const WebApiManager = require('./WebApiManager') +const AuthorizationManager = require('./AuthorizationManager') +const DocumentUpdaterManager = require('./DocumentUpdaterManager') +const ConnectedUsersManager = require('./ConnectedUsersManager') +const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') +const RoomManager = require('./RoomManager') + +module.exports = 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. + PROTOCOL_VERSION: 2, + + joinProject(client, user, project_id, callback) { + if (callback == null) { + callback = function (error, project, privilegeLevel, protocolVersion) {} + } + if (client.disconnected) { + metrics.inc('editor.join-project.disconnected', 1, { + status: 'immediately' + }) + return callback() + } + + const user_id = user != null ? user._id : undefined + logger.log( + { user_id, project_id, client_id: client.id }, + 'user joining project' + ) + metrics.inc('editor.join-project') + return WebApiManager.joinProject(project_id, user, function ( + error, + project, + privilegeLevel, + isRestrictedUser + ) { + if (error != null) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-project.disconnected', 1, { + status: 'after-web-api-call' + }) + return callback() + } + + if (!privilegeLevel || privilegeLevel === '') { + const err = new Error('not authorized') + logger.warn( + { err, project_id, user_id, client_id: client.id }, + 'user is not authorized to join project' + ) + return callback(err) + } + + client.ol_context = {} + client.ol_context.privilege_level = privilegeLevel + client.ol_context.user_id = user_id + client.ol_context.project_id = project_id + client.ol_context.owner_id = __guard__( + project != null ? project.owner : undefined, + (x) => x._id + ) + client.ol_context.first_name = user != null ? user.first_name : undefined + client.ol_context.last_name = user != null ? user.last_name : undefined + client.ol_context.email = user != null ? user.email : undefined + client.ol_context.connected_time = new Date() + client.ol_context.signup_date = user != null ? user.signUpDate : undefined + client.ol_context.login_count = user != null ? user.loginCount : undefined + client.ol_context.is_restricted_user = !!isRestrictedUser + + RoomManager.joinProject(client, project_id, function (err) { + if (err) { + return callback(err) + } + logger.log( + { user_id, project_id, client_id: client.id }, + 'user joined project' + ) + return callback( + null, + project, + privilegeLevel, + WebsocketController.PROTOCOL_VERSION + ) + }) + + // No need to block for setting the user as connected in the cursor tracking + return ConnectedUsersManager.updateUserPosition( + project_id, + client.publicId, + user, + null, + function () {} + ) + }) + }, + + // We want to flush a project if there are no more (local) connected clients + // but we need to wait for the triggering client to disconnect. How long we wait + // is determined by FLUSH_IF_EMPTY_DELAY. + FLUSH_IF_EMPTY_DELAY: 500, // ms + leaveProject(io, client, callback) { + if (callback == null) { + callback = function (error) {} + } + const { project_id, user_id } = client.ol_context + if (!project_id) { + return callback() + } // client did not join project + + metrics.inc('editor.leave-project') + logger.log( + { project_id, user_id, client_id: client.id }, + 'client leaving project' + ) + WebsocketLoadBalancer.emitToRoom( + project_id, + 'clientTracking.clientDisconnected', + client.publicId + ) + + // We can do this in the background + ConnectedUsersManager.markUserAsDisconnected( + project_id, + client.publicId, + function (err) { + if (err != null) { + return logger.error( + { err, project_id, user_id, client_id: client.id }, + 'error marking client as disconnected' + ) + } + } + ) + + RoomManager.leaveProjectAndDocs(client) + return setTimeout(function () { + const remainingClients = io.sockets.clients(project_id) + if (remainingClients.length === 0) { + // Flush project in the background + DocumentUpdaterManager.flushProjectToMongoAndDelete( + project_id, + function (err) { + if (err != null) { + return logger.error( + { err, project_id, user_id, client_id: client.id }, + 'error flushing to doc updater after leaving project' + ) + } + } + ) + } + return callback() + }, WebsocketController.FLUSH_IF_EMPTY_DELAY) + }, + + joinDoc(client, doc_id, fromVersion, options, callback) { + if (fromVersion == null) { + fromVersion = -1 + } + if (callback == null) { + callback = function (error, doclines, version, ops, ranges) {} + } + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { status: 'immediately' }) + return callback() + } + + metrics.inc('editor.join-doc') + const { project_id, user_id, is_restricted_user } = client.ol_context + if (project_id == null) { + return callback(new Error('no project_id found on client')) + } + logger.log( + { user_id, project_id, doc_id, fromVersion, client_id: client.id }, + 'client joining doc' + ) + + return AuthorizationManager.assertClientCanViewProject(client, function ( + error + ) { + if (error != null) { + return callback(error) + } + // ensure the per-doc applied-ops channel is subscribed before sending the + // doc to the client, so that no events are missed. + return RoomManager.joinDoc(client, doc_id, function (error) { + if (error != null) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { + status: 'after-joining-room' + }) + // the client will not read the response anyways + return callback() + } + + return DocumentUpdaterManager.getDocument( + project_id, + doc_id, + fromVersion, + function (error, lines, version, ranges, ops) { + let err + if (error != null) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { + status: 'after-doc-updater-call' + }) + // the client will not read the response anyways + return callback() + } + + if ( + is_restricted_user && + (ranges != null ? ranges.comments : undefined) != null + ) { + ranges.comments = [] + } + + // Encode any binary bits of data so it can go via WebSockets + // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html + const encodeForWebsockets = (text) => + unescape(encodeURIComponent(text)) + const escapedLines = [] + for (let line of Array.from(lines)) { + try { + line = encodeForWebsockets(line) + } catch (error1) { + err = error1 + logger.err( + { + err, + project_id, + doc_id, + fromVersion, + line, + client_id: client.id + }, + 'error encoding line uri component' + ) + return callback(err) + } + escapedLines.push(line) + } + if (options.encodeRanges) { + try { + for (const comment of Array.from( + (ranges != null ? ranges.comments : undefined) || [] + )) { + if (comment.op.c != null) { + comment.op.c = encodeForWebsockets(comment.op.c) + } + } + for (const change of Array.from( + (ranges != null ? ranges.changes : undefined) || [] + )) { + if (change.op.i != null) { + change.op.i = encodeForWebsockets(change.op.i) + } + if (change.op.d != null) { + change.op.d = encodeForWebsockets(change.op.d) + } + } + } catch (error2) { + err = error2 + logger.err( + { + err, + project_id, + doc_id, + fromVersion, + ranges, + client_id: client.id + }, + 'error encoding range uri component' + ) + return callback(err) + } + } + + AuthorizationManager.addAccessToDoc(client, doc_id) + logger.log( + { + user_id, + project_id, + doc_id, + fromVersion, + client_id: client.id + }, + 'client joined doc' + ) + return callback(null, escapedLines, version, ops, ranges) + } + ) + }) + }) + }, + + leaveDoc(client, doc_id, callback) { + // client may have disconnected, but we have to cleanup internal state. + if (callback == null) { + callback = function (error) {} + } + metrics.inc('editor.leave-doc') + const { project_id, user_id } = client.ol_context + logger.log( + { user_id, project_id, doc_id, client_id: client.id }, + 'client leaving doc' + ) + RoomManager.leaveDoc(client, doc_id) + // we could remove permission when user leaves a doc, but because + // the connection is per-project, we continue to allow access + // after the initial joinDoc since we know they are already authorised. + // # AuthorizationManager.removeAccessToDoc client, doc_id + return callback() + }, + updateClientPosition(client, cursorData, callback) { + if (callback == null) { + callback = function (error) {} + } + if (client.disconnected) { + // do not create a ghost entry in redis + return callback() + } + + metrics.inc('editor.update-client-position', 0.1) + const { + project_id, + first_name, + last_name, + email, + user_id + } = client.ol_context + logger.log( + { user_id, project_id, client_id: client.id, cursorData }, + 'updating client position' + ) + + return AuthorizationManager.assertClientCanViewProjectAndDoc( + client, + cursorData.doc_id, + function (error) { + if (error != null) { + logger.warn( + { err: error, client_id: client.id, project_id, user_id }, + "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet." + ) + return callback() + } + cursorData.id = client.publicId + if (user_id != null) { + cursorData.user_id = user_id + } + if (email != null) { + cursorData.email = email + } + // Don't store anonymous users in redis to avoid influx + if (!user_id || user_id === 'anonymous-user') { + cursorData.name = '' + callback() + } else { + cursorData.name = + first_name && last_name + ? `${first_name} ${last_name}` + : first_name || last_name || '' + ConnectedUsersManager.updateUserPosition( + project_id, + client.publicId, + { + first_name, + last_name, + email, + _id: user_id + }, + { + row: cursorData.row, + column: cursorData.column, + doc_id: cursorData.doc_id + }, + callback + ) + } + return WebsocketLoadBalancer.emitToRoom( + project_id, + 'clientTracking.clientUpdated', + cursorData + ) + } + ) + }, + + CLIENT_REFRESH_DELAY: 1000, + getConnectedUsers(client, callback) { + if (callback == null) { + callback = function (error, users) {} + } + if (client.disconnected) { + // they are not interested anymore, skip the redis lookups + return callback() + } + + metrics.inc('editor.get-connected-users') + const { project_id, user_id, is_restricted_user } = client.ol_context + if (is_restricted_user) { + return callback(null, []) + } + if (project_id == null) { + return callback(new Error('no project_id found on client')) + } + logger.log( + { user_id, project_id, client_id: client.id }, + 'getting connected users' + ) + return AuthorizationManager.assertClientCanViewProject(client, function ( + error + ) { + if (error != null) { + return callback(error) + } + WebsocketLoadBalancer.emitToRoom(project_id, 'clientTracking.refresh') + return setTimeout( + () => + ConnectedUsersManager.getConnectedUsers(project_id, function ( + error, + users + ) { + if (error != null) { + return callback(error) + } + callback(null, users) + return logger.log( + { user_id, project_id, client_id: client.id }, + 'got connected users' + ) + }), + WebsocketController.CLIENT_REFRESH_DELAY + ) + }) + }, + + applyOtUpdate(client, doc_id, update, callback) { + // client may have disconnected, but we can submit their update to doc-updater anyways. + if (callback == null) { + callback = function (error) {} + } + const { user_id, project_id } = client.ol_context + if (project_id == null) { + return callback(new Error('no project_id found on client')) + } + + return WebsocketController._assertClientCanApplyUpdate( + client, + doc_id, + update, + function (error) { + if (error != null) { + logger.warn( + { err: error, doc_id, client_id: client.id, version: update.v }, + 'client is not authorized to make update' + ) + setTimeout( + () => + // Disconnect, but give the client the chance to receive the error + client.disconnect(), + 100 + ) + return callback(error) + } + if (!update.meta) { + update.meta = {} + } + update.meta.source = client.publicId + update.meta.user_id = user_id + metrics.inc('editor.doc-update', 0.3) + + logger.log( + { + user_id, + doc_id, + project_id, + client_id: client.id, + version: update.v + }, + 'sending update to doc updater' + ) + + return DocumentUpdaterManager.queueChange( + project_id, + doc_id, + update, + function (error) { + if ( + (error != null ? error.message : undefined) === + 'update is too large' + ) { + metrics.inc('update_too_large') + const { updateSize } = error + logger.warn( + { user_id, project_id, doc_id, updateSize }, + 'update is too large' + ) + + // mark the update as received -- the client should not send it again! + callback() + + // trigger an out-of-sync error + const message = { + project_id, + doc_id, + error: 'update is too large' + } + setTimeout(function () { + if (client.disconnected) { + // skip the message broadcast, the client has moved on + return metrics.inc('editor.doc-update.disconnected', 1, { + status: 'at-otUpdateError' + }) + } + client.emit('otUpdateError', message.error, message) + return client.disconnect() + }, 100) + return + } + + if (error != null) { + logger.error( + { + err: error, + project_id, + doc_id, + client_id: client.id, + version: update.v + }, + 'document was not available for update' + ) + client.disconnect() + } + return callback(error) + } + ) + } + ) + }, + + _assertClientCanApplyUpdate(client, doc_id, update, callback) { + return AuthorizationManager.assertClientCanEditProjectAndDoc( + client, + doc_id, + function (error) { + if (error != null) { + if ( + error.message === 'not authorized' && + WebsocketController._isCommentUpdate(update) + ) { + // This might be a comment op, which we only need read-only priveleges for + return AuthorizationManager.assertClientCanViewProjectAndDoc( + client, + doc_id, + callback + ) + } else { + return callback(error) + } + } else { + return callback(null) + } + } + ) + }, + + _isCommentUpdate(update) { + for (const op of Array.from(update.op)) { + if (op.c == null) { + return false + } + } + return true + } +} + +function __guard__(value, transform) { + return typeof value !== 'undefined' && value !== null + ? transform(value) + : undefined +} diff --git a/services/real-time/app/js/WebsocketLoadBalancer.js b/services/real-time/app/js/WebsocketLoadBalancer.js new file mode 100644 index 0000000000..2719921f10 --- /dev/null +++ b/services/real-time/app/js/WebsocketLoadBalancer.js @@ -0,0 +1,217 @@ +/* eslint-disable + camelcase, +*/ +// 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 + * 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 + */ +let WebsocketLoadBalancer +const Settings = require('settings-sharelatex') +const logger = require('logger-sharelatex') +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') + +const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [ + 'connectionAccepted', + 'otUpdateApplied', + 'otUpdateError', + 'joinDoc', + 'reciveNewDoc', + 'reciveNewFile', + 'reciveNewFolder', + 'removeEntity' +] + +module.exports = WebsocketLoadBalancer = { + rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub), + rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub), + + emitToRoom(room_id, message, ...payload) { + if (room_id == null) { + logger.warn( + { message, payload }, + 'no room_id provided, ignoring emitToRoom' + ) + return + } + const data = JSON.stringify({ + room_id, + message, + payload + }) + logger.log( + { room_id, message, payload, length: data.length }, + 'emitting to room' + ) + + return Array.from(this.rclientPubList).map((rclientPub) => + ChannelManager.publish(rclientPub, 'editor-events', room_id, data) + ) + }, + + emitToAll(message, ...payload) { + return this.emitToRoom('all', message, ...Array.from(payload)) + }, + + listenForEditorEvents(io) { + logger.log( + { rclients: this.rclientPubList.length }, + 'publishing editor events' + ) + logger.log( + { rclients: this.rclientSubList.length }, + 'listening for editor events' + ) + for (const rclientSub of Array.from(this.rclientSubList)) { + rclientSub.subscribe('editor-events') + rclientSub.on('message', function (channel, message) { + if (Settings.debugEvents > 0) { + EventLogger.debugEvent(channel, message) + } + return WebsocketLoadBalancer._processEditorEvent(io, channel, message) + }) + } + return this.handleRoomUpdates(this.rclientSubList) + }, + + handleRoomUpdates(rclientSubList) { + const roomEvents = RoomManager.eventSource() + roomEvents.on('project-active', function (project_id) { + const subscribePromises = Array.from(rclientSubList).map((rclient) => + ChannelManager.subscribe(rclient, 'editor-events', project_id) + ) + return RoomManager.emitOnCompletion( + subscribePromises, + `project-subscribed-${project_id}` + ) + }) + return roomEvents.on('project-empty', (project_id) => + Array.from(rclientSubList).map((rclient) => + ChannelManager.unsubscribe(rclient, 'editor-events', project_id) + ) + ) + }, + + _processEditorEvent(io, channel, message) { + return SafeJsonParse.parse(message, function (error, message) { + let clientList + let client + if (error != null) { + logger.error({ err: error, channel }, 'error parsing JSON') + return + } + if (message.room_id === 'all') { + return io.sockets.emit(message.message, ...Array.from(message.payload)) + } else if ( + message.message === 'clientTracking.refresh' && + message.room_id != null + ) { + clientList = io.sockets.clients(message.room_id) + logger.log( + { + channel, + message: message.message, + room_id: message.room_id, + message_id: message._id, + socketIoClients: (() => { + const result = [] + for (client of Array.from(clientList)) { + result.push(client.id) + } + return result + })() + }, + 'refreshing client list' + ) + return (() => { + const result1 = [] + for (client of Array.from(clientList)) { + result1.push( + ConnectedUsersManager.refreshClient( + message.room_id, + client.publicId + ) + ) + } + return result1 + })() + } else if (message.room_id != null) { + if (message._id != null && Settings.checkEventOrder) { + const status = EventLogger.checkEventOrder( + 'editor-events', + message._id, + message + ) + if (status === 'duplicate') { + return // skip duplicate events + } + } + + const is_restricted_message = !Array.from( + RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST + ).includes(message.message) + + // send messages only to unique clients (due to duplicate entries in io.sockets.clients) + clientList = io.sockets + .clients(message.room_id) + .filter( + (client) => + !(is_restricted_message && client.ol_context.is_restricted_user) + ) + + // avoid unnecessary work if no clients are connected + if (clientList.length === 0) { + return + } + logger.log( + { + channel, + message: message.message, + room_id: message.room_id, + message_id: message._id, + socketIoClients: (() => { + const result2 = [] + for (client of Array.from(clientList)) { + result2.push(client.id) + } + return result2 + })() + }, + 'distributing event to clients' + ) + const seen = {} + return (() => { + const result3 = [] + for (client of Array.from(clientList)) { + if (!seen[client.id]) { + seen[client.id] = true + result3.push( + client.emit(message.message, ...Array.from(message.payload)) + ) + } else { + result3.push(undefined) + } + } + return result3 + })() + } else if (message.health_check != null) { + logger.debug( + { message }, + 'got health check message in editor events channel' + ) + return HealthCheckManager.check(channel, message.key) + } + }) + } +} diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt index 7e1b0350d4..526bd01e14 100644 --- a/services/real-time/buildscript.txt +++ b/services/real-time/buildscript.txt @@ -1,10 +1,10 @@ real-time ---public-repo=True ---language=coffeescript ---env-add= ---node-version=10.21.0 --acceptance-creds=None --dependencies=redis --docker-repos=gcr.io/overleaf-ops +--env-add= --env-pass-through= ---script-version=1.3.5 +--language=es +--node-version=10.21.0 +--public-repo=True +--script-version=2.3.0 diff --git a/services/real-time/config/settings.defaults.coffee b/services/real-time/config/settings.defaults.coffee deleted file mode 100644 index ee4bd74316..0000000000 --- a/services/real-time/config/settings.defaults.coffee +++ /dev/null @@ -1,79 +0,0 @@ -settings = - redis: - - pubsub: - host: process.env['PUBSUB_REDIS_HOST'] or process.env['REDIS_HOST'] or "localhost" - port: process.env['PUBSUB_REDIS_PORT'] or process.env['REDIS_PORT'] or "6379" - password: process.env["PUBSUB_REDIS_PASSWORD"] or process.env["REDIS_PASSWORD"] or "" - maxRetriesPerRequest: parseInt(process.env["PUBSUB_REDIS_MAX_RETRIES_PER_REQUEST"] or process.env["REDIS_MAX_RETRIES_PER_REQUEST"] or "20") - - realtime: - host: process.env['REAL_TIME_REDIS_HOST'] or process.env['REDIS_HOST'] or "localhost" - port: process.env['REAL_TIME_REDIS_PORT'] or process.env['REDIS_PORT'] or "6379" - password: process.env["REAL_TIME_REDIS_PASSWORD"] or process.env["REDIS_PASSWORD"] or "" - key_schema: - clientsInProject: ({project_id}) -> "clients_in_project:{#{project_id}}" - connectedUser: ({project_id, client_id})-> "connected_user:{#{project_id}}:#{client_id}" - maxRetriesPerRequest: parseInt(process.env["REAL_TIME_REDIS_MAX_RETRIES_PER_REQUEST"] or process.env["REDIS_MAX_RETRIES_PER_REQUEST"] or "20") - - documentupdater: - host: process.env['DOC_UPDATER_REDIS_HOST'] or process.env['REDIS_HOST'] or "localhost" - port: process.env['DOC_UPDATER_REDIS_PORT'] or process.env['REDIS_PORT'] or "6379" - password: process.env["DOC_UPDATER_REDIS_PASSWORD"] or process.env["REDIS_PASSWORD"] or "" - key_schema: - pendingUpdates: ({doc_id}) -> "PendingUpdates:{#{doc_id}}" - maxRetriesPerRequest: parseInt(process.env["DOC_UPDATER_REDIS_MAX_RETRIES_PER_REQUEST"] or process.env["REDIS_MAX_RETRIES_PER_REQUEST"] or "20") - - websessions: - host: process.env['WEB_REDIS_HOST'] or process.env['REDIS_HOST'] or "localhost" - port: process.env['WEB_REDIS_PORT'] or process.env['REDIS_PORT'] or "6379" - password: process.env["WEB_REDIS_PASSWORD"] or process.env["REDIS_PASSWORD"] or "" - maxRetriesPerRequest: parseInt(process.env["WEB_REDIS_MAX_RETRIES_PER_REQUEST"] or process.env["REDIS_MAX_RETRIES_PER_REQUEST"] or "20") - - internal: - realTime: - port: 3026 - host: process.env['LISTEN_ADDRESS'] or "localhost" - user: "sharelatex" - pass: "password" - - apis: - web: - url: "http://#{process.env['WEB_API_HOST'] or process.env['WEB_HOST'] or "localhost"}:#{process.env['WEB_API_PORT'] or process.env['WEB_PORT'] or 3000}" - user: process.env['WEB_API_USER'] or "sharelatex" - pass: process.env['WEB_API_PASSWORD'] or "password" - documentupdater: - url: "http://#{process.env['DOCUMENT_UPDATER_HOST'] or process.env['DOCUPDATER_HOST'] or "localhost"}:3003" - - security: - sessionSecret: process.env['SESSION_SECRET'] or "secret-please-change" - - cookieName: process.env['COOKIE_NAME'] or "sharelatex.sid" - - max_doc_length: 2 * 1024 * 1024 # 2mb - - # combine - # max_doc_length (2mb see above) * 2 (delete + insert) - # max_ranges_size (3mb see MAX_RANGES_SIZE in document-updater) - # overhead for JSON serialization - maxUpdateSize: parseInt(process.env['MAX_UPDATE_SIZE']) or 7 * 1024 * 1024 + 64 * 1024 - - shutdownDrainTimeWindow: process.env['SHUTDOWN_DRAIN_TIME_WINDOW'] or 9 - - continualPubsubTraffic: process.env['CONTINUAL_PUBSUB_TRAFFIC'] or false - - checkEventOrder: process.env['CHECK_EVENT_ORDER'] or false - - publishOnIndividualChannels: process.env['PUBLISH_ON_INDIVIDUAL_CHANNELS'] or false - - statusCheckInterval: parseInt(process.env['STATUS_CHECK_INTERVAL'] or '0') - - sentry: - dsn: process.env.SENTRY_DSN - - errors: - catchUncaughtErrors: true - shutdownOnUncaughtError: true - -# console.log settings.redis -module.exports = settings diff --git a/services/real-time/config/settings.defaults.js b/services/real-time/config/settings.defaults.js new file mode 100644 index 0000000000..1e4ec6fe72 --- /dev/null +++ b/services/real-time/config/settings.defaults.js @@ -0,0 +1,143 @@ +/* eslint-disable camelcase */ + +const settings = { + redis: { + pubsub: { + host: + process.env.PUBSUB_REDIS_HOST || process.env.REDIS_HOST || 'localhost', + port: process.env.PUBSUB_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.PUBSUB_REDIS_PASSWORD || process.env.REDIS_PASSWORD || '', + maxRetriesPerRequest: parseInt( + process.env.PUBSUB_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ) + }, + + realtime: { + host: + process.env.REAL_TIME_REDIS_HOST || + process.env.REDIS_HOST || + 'localhost', + port: + process.env.REAL_TIME_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.REAL_TIME_REDIS_PASSWORD || + process.env.REDIS_PASSWORD || + '', + key_schema: { + clientsInProject({ project_id }) { + return `clients_in_project:{${project_id}}` + }, + connectedUser({ project_id, client_id }) { + return `connected_user:{${project_id}}:${client_id}` + } + }, + maxRetriesPerRequest: parseInt( + process.env.REAL_TIME_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ) + }, + + documentupdater: { + host: + process.env.DOC_UPDATER_REDIS_HOST || + process.env.REDIS_HOST || + 'localhost', + port: + process.env.DOC_UPDATER_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.DOC_UPDATER_REDIS_PASSWORD || + process.env.REDIS_PASSWORD || + '', + key_schema: { + pendingUpdates({ doc_id }) { + return `PendingUpdates:{${doc_id}}` + } + }, + maxRetriesPerRequest: parseInt( + process.env.DOC_UPDATER_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ) + }, + + websessions: { + host: process.env.WEB_REDIS_HOST || process.env.REDIS_HOST || 'localhost', + port: process.env.WEB_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.WEB_REDIS_PASSWORD || process.env.REDIS_PASSWORD || '', + maxRetriesPerRequest: parseInt( + process.env.WEB_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ) + } + }, + + internal: { + realTime: { + port: 3026, + host: process.env.LISTEN_ADDRESS || 'localhost', + user: 'sharelatex', + pass: 'password' + } + }, + + apis: { + web: { + url: `http://${ + process.env.WEB_API_HOST || process.env.WEB_HOST || 'localhost' + }:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`, + user: process.env.WEB_API_USER || 'sharelatex', + pass: process.env.WEB_API_PASSWORD || 'password' + }, + documentupdater: { + url: `http://${ + process.env.DOCUMENT_UPDATER_HOST || + process.env.DOCUPDATER_HOST || + 'localhost' + }:3003` + } + }, + + security: { + sessionSecret: process.env.SESSION_SECRET || 'secret-please-change' + }, + + cookieName: process.env.COOKIE_NAME || 'sharelatex.sid', + + max_doc_length: 2 * 1024 * 1024, // 2mb + + // combine + // max_doc_length (2mb see above) * 2 (delete + insert) + // max_ranges_size (3mb see MAX_RANGES_SIZE in document-updater) + // overhead for JSON serialization + maxUpdateSize: + parseInt(process.env.MAX_UPDATE_SIZE) || 7 * 1024 * 1024 + 64 * 1024, + + shutdownDrainTimeWindow: process.env.SHUTDOWN_DRAIN_TIME_WINDOW || 9, + + continualPubsubTraffic: process.env.CONTINUAL_PUBSUB_TRAFFIC || false, + + checkEventOrder: process.env.CHECK_EVENT_ORDER || false, + + publishOnIndividualChannels: + process.env.PUBLISH_ON_INDIVIDUAL_CHANNELS || false, + + statusCheckInterval: parseInt(process.env.STATUS_CHECK_INTERVAL || '0'), + + sentry: { + dsn: process.env.SENTRY_DSN + }, + + errors: { + catchUncaughtErrors: true, + shutdownOnUncaughtError: true + } +} + +// console.log settings.redis +module.exports = settings diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml index 292c297cc3..f9fc7b983e 100644 --- a/services/real-time/docker-compose.ci.yml +++ b/services/real-time/docker-compose.ci.yml @@ -1,7 +1,6 @@ # This file was auto-generated, do not edit it directly. # Instead run bin/update_build_scripts from # https://github.com/sharelatex/sharelatex-dev-environment -# Version: 1.3.5 version: "2.3" diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml index b174363e67..8570f506ae 100644 --- a/services/real-time/docker-compose.yml +++ b/services/real-time/docker-compose.yml @@ -1,7 +1,6 @@ # This file was auto-generated, do not edit it directly. # Instead run bin/update_build_scripts from # https://github.com/sharelatex/sharelatex-dev-environment -# Version: 1.3.5 version: "2.3" diff --git a/services/real-time/nodemon.json b/services/real-time/nodemon.json index 98db38d71b..5826281b84 100644 --- a/services/real-time/nodemon.json +++ b/services/real-time/nodemon.json @@ -10,10 +10,9 @@ }, "watch": [ - "app/coffee/", - "app.coffee", + "app/js/", + "app.js", "config/" ], - "ext": "coffee" - + "ext": "js" } diff --git a/services/real-time/package-lock.json b/services/real-time/package-lock.json index b6b2a7a811..616f0edea9 100644 --- a/services/real-time/package-lock.json +++ b/services/real-time/package-lock.json @@ -4,6 +4,32 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@babel/code-frame": { + "version": "7.10.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.3.tgz", + "integrity": "sha512-fDx9eNW0qz0WkUeqL6tXEXzVlPh6Y5aCDEZesl0xBGA8ndRukX91Uk44ZqnkECp01NAZUdCAl+aiQNGi0k88Eg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.3" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.3.tgz", + "integrity": "sha512-bU8JvtlYpJSBPuj1VUmKpFGaDZuLxASky3LhaKj3bmpSTY6VWooSM8msk+Z0CZoErFye2tlABF6yDkT3FOPAXw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.10.3", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.3.tgz", + "integrity": "sha512-Ih9B/u7AtgEnySE2L2F0Xm0GaM729XqqLfHkalTsbjXGyqmf/6M0Cu0WpvqueUlW+xk88BHw9Nkpj49naU+vWw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.3", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, "@google-cloud/common": { "version": "0.32.1", "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-0.32.1.tgz", @@ -244,6 +270,12 @@ "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, "@types/console-log-level": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@types/console-log-level/-/console-log-level-1.4.0.tgz", @@ -257,6 +289,24 @@ "@types/node": "*" } }, + "@types/eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz", + "integrity": "sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==", + "dev": true + }, + "@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, "@types/long": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", @@ -300,6 +350,59 @@ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.6.tgz", "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==" }, + "@typescript-eslint/experimental-utils": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-1.13.0.tgz", + "integrity": "sha512-zmpS6SyqG4ZF64ffaJ6uah6tWWWgZ8m+c54XXgwFtUv0jNz8aJAVx8chMCvnk7yl6xwn8d+d96+tWp7fXzTuDg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/typescript-estree": "1.13.0", + "eslint-scope": "^4.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + } + } + }, + "@typescript-eslint/parser": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-1.13.0.tgz", + "integrity": "sha512-ITMBs52PCPgLb2nGPoeT4iU3HdQZHcPaZVw+7CsFagRJHUhyeTgorEwHXhFf3e7Evzi8oujKNpHc8TONth8AdQ==", + "dev": true, + "requires": { + "@types/eslint-visitor-keys": "^1.0.0", + "@typescript-eslint/experimental-utils": "1.13.0", + "@typescript-eslint/typescript-estree": "1.13.0", + "eslint-visitor-keys": "^1.0.0" + } + }, + "@typescript-eslint/typescript-estree": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-1.13.0.tgz", + "integrity": "sha512-b5rCmd2e6DCC6tCTN9GSUAuxdYwCM/k/2wdjHGrIRGPSJotWMCe/dGpi66u42bhuh8q3QBzqM4TMA1GUUCJvdw==", + "dev": true, + "requires": { + "lodash.unescape": "4.0.1", + "semver": "5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "dev": true + } + } + }, "abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -337,6 +440,12 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==" }, + "acorn-jsx": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.2.0.tgz", + "integrity": "sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ==", + "dev": true + }, "active-x-obfuscator": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/active-x-obfuscator/-/active-x-obfuscator-0.0.1.tgz", @@ -364,11 +473,73 @@ "uri-js": "^4.2.2" } }, + "ansi-escapes": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", + "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", + "dev": true, + "requires": { + "type-fest": "^0.11.0" + }, + "dependencies": { + "type-fest": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", + "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", + "dev": true + } + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "array-includes": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz", + "integrity": "sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0", + "is-string": "^1.0.5" + } + }, + "array.prototype.flat": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz", + "integrity": "sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" + } + }, "arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", @@ -393,6 +564,12 @@ "integrity": "sha512-g/gZV+G476cnmtYI+Ko9d5khxSoCSoom/EaNmmCfwpOvBXEJ18qwFrxfP1/CsIqk2no1sAKKwxndV0tP7ROOFQ==", "dev": true }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, "async": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", @@ -501,6 +678,12 @@ "type-is": "~1.6.17" } }, + "boolify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/boolify/-/boolify-1.0.1.tgz", + "integrity": "sha512-ma2q0Tc760dW54CdOyJjhrg/a54317o1zYADQJFgperNGKIKgAUGIcKnuMiff8z57+yGlrGNEt4lPgZfCgTJgA==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -541,6 +724,29 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + } + }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -556,6 +762,74 @@ "deep-eql": "0.1.3" } }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-width": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", + "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + }, + "dependencies": { + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + } + } + }, "cluster-key-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", @@ -566,6 +840,21 @@ "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.6.0.tgz", "integrity": "sha512-Tx8itEfCsQp8RbLDFt7qwjqXycAx2g6SI7//4PPUR2j6meLmNifYm6zKrNDcU1+Q/GWRhjhEZk7DaLG1TfIzGA==" }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, "combined-stream": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", @@ -574,6 +863,12 @@ "delayed-stream": "~1.0.0" } }, + "common-tags": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", + "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -603,6 +898,12 @@ "resolved": "https://registry.npmjs.org/console-log-level/-/console-log-level-1.4.1.tgz", "integrity": "sha512-VZzbIORbP+PPcN/gg3DXClTLPLg5Slwd5fL2MIc+o1qZ4BXBvWyc6QxPk6T/Mkr6IVjRpoAGf32XxP3ZWMVRcQ==" }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha512-OKZnPGeMQy2RPaUIBPFFd71iNf4791H12MCRuVQDnzGRwCYNYmTDy5pdafo2SLAcEMKzTOQnLWG4QdcjeJUMEg==", + "dev": true + }, "content-disposition": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", @@ -664,11 +965,38 @@ "integrity": "sha512-Alvs19Vgq07eunykd3Xy2jF0/qSNv2u7KDbAek9H5liV1UMijbqFs5cycZvv5dVsvseT/U4H8/7/w8Koh35C4A==", "dev": true }, + "core-js": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", + "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", + "dev": true + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -685,6 +1013,12 @@ "ms": "2.0.0" } }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, "deep-eql": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", @@ -694,6 +1028,21 @@ "type-detect": "0.1.1" } }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha512-GtxAN4HvBachZzm4OnWqc45ESpUCMwkYcsjnsPs23FwJbsO+k4t0k9bQCgOmzIlpHO28+WPK/KRbRk0DDHuuDw==", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, "delay": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/delay/-/delay-4.3.0.tgz", @@ -725,6 +1074,21 @@ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, "dtrace-provider": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.2.8.tgz", @@ -773,6 +1137,12 @@ "shimmer": "^1.2.0" } }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -791,6 +1161,45 @@ "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==" }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -815,6 +1224,345 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true }, + "eslint": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", + "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.10.0", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^5.0.0", + "eslint-utils": "^1.4.3", + "eslint-visitor-keys": "^1.1.0", + "espree": "^6.1.2", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^7.0.0", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.14", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.3", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^6.1.2", + "strip-ansi": "^5.2.0", + "strip-json-comments": "^3.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "eslint-config-prettier": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz", + "integrity": "sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==", + "dev": true, + "requires": { + "get-stdin": "^6.0.0" + } + }, + "eslint-config-standard": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-14.1.1.tgz", + "integrity": "sha512-Z9B+VR+JIXRxz21udPTL9HpFMyoMUEeX1G251EQ6e05WD9aPVtVBn09XUmZ259wCMlCDmYDSZG62Hhm+ZTJcUg==", + "dev": true + }, + "eslint-import-resolver-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz", + "integrity": "sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.13.1" + } + }, + "eslint-module-utils": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz", + "integrity": "sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "pkg-dir": "^2.0.0" + } + }, + "eslint-plugin-chai-expect": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-chai-expect/-/eslint-plugin-chai-expect-2.1.0.tgz", + "integrity": "sha512-rd0/4mjMV6c3i0o4DKkWI4uaFN9DK707kW+/fDphaDI6HVgxXnhML9Xgt5vHnTXmSSnDhupuCFBgsEAEpchXmQ==", + "dev": true + }, + "eslint-plugin-chai-friendly": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-chai-friendly/-/eslint-plugin-chai-friendly-0.5.0.tgz", + "integrity": "sha512-Pxe6z8C9fP0pn2X2nGFU/b3GBOCM/5FVus1hsMwJsXP3R7RiXFl7g0ksJbsc0GxiLyidTW4mEFk77qsNn7Tk7g==", + "dev": true + }, + "eslint-plugin-es": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", + "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", + "dev": true, + "requires": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "dependencies": { + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "regexpp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", + "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "dev": true + } + } + }, + "eslint-plugin-import": { + "version": "2.21.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.21.2.tgz", + "integrity": "sha512-FEmxeGI6yaz+SnEB6YgNHlQK1Bs2DKLM+YF+vuTk5H8J9CLbJLtlPvRFgZZ2+sXiKAlN5dpdlrWOjK8ZoZJpQA==", + "dev": true, + "requires": { + "array-includes": "^3.1.1", + "array.prototype.flat": "^1.2.3", + "contains-path": "^0.1.0", + "debug": "^2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.3", + "eslint-module-utils": "^2.6.0", + "has": "^1.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.1", + "read-pkg-up": "^2.0.0", + "resolve": "^1.17.0", + "tsconfig-paths": "^3.9.0" + }, + "dependencies": { + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha512-lsGyRuYr4/PIB0txi+Fy2xOMI2dGaTguCaotzFGkVZuKR5usKfcRWIFKNM3QNrU7hh/+w2bwTW+ZeXPK5l8uVg==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "isarray": "^1.0.0" + } + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "eslint-plugin-mocha": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-6.3.0.tgz", + "integrity": "sha512-Cd2roo8caAyG21oKaaNTj7cqeYRWW1I2B5SfpKRp0Ip1gkfwoR1Ow0IGlPWnNjzywdF4n+kHL8/9vM6zCJUxdg==", + "dev": true, + "requires": { + "eslint-utils": "^2.0.0", + "ramda": "^0.27.0" + }, + "dependencies": { + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + } + } + }, + "eslint-plugin-node": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", + "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "dev": true, + "requires": { + "eslint-plugin-es": "^3.0.0", + "eslint-utils": "^2.0.0", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "dependencies": { + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + } + } + }, + "eslint-plugin-prettier": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz", + "integrity": "sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, + "eslint-plugin-promise": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz", + "integrity": "sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==", + "dev": true + }, + "eslint-plugin-standard": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz", + "integrity": "sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz", + "integrity": "sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", + "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + }, + "espree": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", + "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", + "dev": true, + "requires": { + "acorn": "^7.1.1", + "acorn-jsx": "^5.2.0", + "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "acorn": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz", + "integrity": "sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", + "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz", + "integrity": "sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -921,6 +1669,17 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -931,16 +1690,46 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, "fast-text-encoding": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.1.tgz", "integrity": "sha512-x4FEgaz3zNRtJfLFqJmHWxkMDDvXVtaznj2V9jiP8ACUJrUgist4bP9FmDL2Vew2Y9mEQI/tG4GqabaitYp9CQ==" }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -960,11 +1749,62 @@ "unpipe": "~1.0.0" } }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, "findit2": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/findit2/-/findit2-2.2.3.tgz", "integrity": "sha512-lg/Moejf4qXovVutL0Lz4IsaPoNYMuxt4PA0nGqFxnJ1CTTGGlEO2wKgoDpwknhvZ8k4Q2F+eesgkLbG2Mxfog==" }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "flatted": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", + "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", + "dev": true + }, "follow-redirects": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", @@ -1023,6 +1863,18 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true + }, "gaxios": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-1.8.4.tgz", @@ -1043,6 +1895,18 @@ "json-bigint": "^0.3.0" } }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-stdin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", + "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==", + "dev": true + }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -1064,6 +1928,24 @@ "path-is-absolute": "^1.0.0" } }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", + "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, "google-auth-library": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-3.1.2.tgz", @@ -1096,6 +1978,12 @@ "pify": "^4.0.0" } }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, "gtoken": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-2.3.3.tgz", @@ -1129,6 +2017,44 @@ "har-schema": "^2.0.0" } }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true + } + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, "he": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", @@ -1140,6 +2066,12 @@ "resolved": "https://registry.npmjs.org/hex2dec/-/hex2dec-1.1.2.tgz", "integrity": "sha512-Yu+q/XWr2fFQ11tHxPq4p4EiNkb2y+lAacJNhAdRXVfRIcDH6gi7htWFnnlIzvqHMHoWeIsfXlNAjZInpAOJDA==" }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -1194,6 +2126,34 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "import-fresh": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", + "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1208,34 +2168,85 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" }, - "ioredis": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.17.3.tgz", - "integrity": "sha512-iRvq4BOYzNFkDnSyhx7cmJNOi1x/HWYe+A4VXHBu4qpwJaGT1Mp+D2bVGJntH9K/Z/GeOM/Nprb8gB3bmitz1Q==", + "inquirer": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.2.0.tgz", + "integrity": "sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ==", + "dev": true, "requires": { - "cluster-key-slot": "^1.1.0", - "debug": "^4.1.1", - "denque": "^1.1.0", - "lodash.defaults": "^4.2.0", - "lodash.flatten": "^4.4.0", - "redis-commands": "1.5.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.0.1" + "ansi-escapes": "^4.2.1", + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.15", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.5.3", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" }, "dependencies": { - "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, "requires": { - "ms": "^2.1.1" + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" } }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } } } }, @@ -1249,11 +2260,74 @@ "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==" }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "is-buffer": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==" }, + "is-callable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz", + "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==", + "dev": true + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.0.tgz", + "integrity": "sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -1264,11 +2338,33 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", @@ -1292,11 +2388,34 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -1327,6 +2446,46 @@ "safe-buffer": "^5.0.1" } }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha512-3p6ZOGNbiX4CdvEd1VcE6yi78UrGNpjHO33noGwHCnT/o2fyllJDepsm8+mFFv/DvtwFHht5HIHSyOy5a+ChVQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + } + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", @@ -1342,11 +2501,29 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, "lodash.pickby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==" }, + "lodash.unescape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", + "integrity": "sha512-DhhGRshNS1aX6s5YdBE3njCCouPgnG29ebyHvImlZzXZf2SHgt+J08DHgytTPnpywNbO1Y8mNUFyQuIDBq2JZg==", + "dev": true + }, "logger-sharelatex": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/logger-sharelatex/-/logger-sharelatex-1.7.0.tgz", @@ -1434,6 +2611,64 @@ } } }, + "loglevel": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz", + "integrity": "sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA==", + "dev": true + }, + "loglevel-colored-level-prefix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz", + "integrity": "sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "loglevel": "^1.4.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true + } + } + }, "lolex": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.6.0.tgz", @@ -1467,6 +2702,30 @@ "statsd-parser": "~0.0.4" } }, + "make-plural": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-4.3.0.tgz", + "integrity": "sha512-xTYd4JVHpSCW+aqDof6w/MebaMVNTVYBZhbB/vi513xXdiPT92JMVCo0Jq8W2UZnzYRFeVbQiQ+I25l13JuKvA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true, + "optional": true + } + } + }, + "map-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.1.0.tgz", + "integrity": "sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==", + "dev": true + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1482,6 +2741,29 @@ "resolved": "https://registry.npmjs.org/mersenne/-/mersenne-0.0.4.tgz", "integrity": "sha512-XoSUL+nF8hMTKGQxUs8r3Btdsf1yuKKBdCCGbh3YXgCXuVKishpZv1CNc385w9s8t4Ynwc5h61BwW/FCVulkbg==" }, + "messageformat": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/messageformat/-/messageformat-2.3.0.tgz", + "integrity": "sha512-uTzvsv0lTeQxYI2y1NPa1lItL5VRI8Gb93Y2K2ue5gBPyrbJxfDi/EYWxh2PKv5yO42AJeeqblS9MJSh/IEk4w==", + "dev": true, + "requires": { + "make-plural": "^4.3.0", + "messageformat-formatters": "^2.0.1", + "messageformat-parser": "^4.1.2" + } + }, + "messageformat-formatters": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/messageformat-formatters/-/messageformat-formatters-2.0.1.tgz", + "integrity": "sha512-E/lQRXhtHwGuiQjI7qxkLp8AHbMD5r2217XNe/SREbBlSawe0lOqsFb7rflZJmlQFSULNLIqlcjjsCPlB3m3Mg==", + "dev": true + }, + "messageformat-parser": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/messageformat-parser/-/messageformat-parser-4.1.3.tgz", + "integrity": "sha512-2fU3XDCanRqeOCkn7R5zW5VQHWf+T3hH65SzuqRvjatBK7r4uyFa5mEX+k6F9Bd04LVM5G4/BHBTUJsOdW7uyg==", + "dev": true + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -1527,6 +2809,12 @@ "mime-db": "~1.33.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -1640,6 +2928,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, "mv": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", @@ -1662,6 +2956,12 @@ "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", "dev": true }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "ncp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", @@ -1673,6 +2973,12 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, "node-fetch": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", @@ -1683,11 +2989,67 @@ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==" }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.values": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz", + "integrity": "sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -1709,11 +3071,40 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, "options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", "integrity": "sha512-bOj3L1ypm++N+n7CEbbe473A414AB7z+amKYshRb//iuL3MpdDCLhPnw6aVTdKB9g5ZRVHIEp8eUln6L2NUStg==" }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true + }, "p-limit": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", @@ -1722,16 +3113,60 @@ "p-try": "^2.0.0" } }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + }, + "dependencies": { + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true + } + } + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, "parse-duration": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-0.1.2.tgz", "integrity": "sha512-0qfMZyjOUFBeEIvJ5EayfXJqaEXxQ+Oj2b7tWJM3hvEXvXsYCk05EDVI23oYnEw2NaFYUWdABEVPBvBMh8L/pA==" }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, "parse-ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", @@ -1742,11 +3177,29 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", @@ -1757,6 +3210,23 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==", + "dev": true, + "requires": { + "pify": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + } + } + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -1767,11 +3237,641 @@ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha512-ojakdnUgL5pzJYWw2AIDEupaQCX5OPbM688ZevubICjdIX01PRSYKqm33fJoCOJBRseYCTUlQRnBNX+Pchaejw==", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, "policyfile": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/policyfile/-/policyfile-0.0.4.tgz", "integrity": "sha512-UfDtlscNialXfmVEwEPm0t/5qtM0xPK025eYWd/ilv89hxLIhVQmt3QIzMHincLO2MBtZyww0386pt13J4aIhQ==" }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true + }, + "prettier": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz", + "integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==", + "dev": true + }, + "prettier-eslint": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-9.0.2.tgz", + "integrity": "sha512-u6EQqxUhaGfra9gy9shcR7MT7r/2twwEfRGy1tfzyaJvLQwSg34M9IU5HuF7FsLW2QUgr5VIUc56EPWibw1pdw==", + "dev": true, + "requires": { + "@typescript-eslint/parser": "^1.10.2", + "common-tags": "^1.4.0", + "core-js": "^3.1.4", + "dlv": "^1.1.0", + "eslint": "^5.0.0", + "indent-string": "^4.0.0", + "lodash.merge": "^4.6.0", + "loglevel-colored-level-prefix": "^1.0.0", + "prettier": "^1.7.0", + "pretty-format": "^23.0.1", + "require-relative": "^0.8.7", + "typescript": "^3.2.1", + "vue-eslint-parser": "^2.0.2" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha512-wFUFA5bg5dviipbQQ32yOQhl6gcJaJXiHE7dvR8VYPG97+J/GNC5FKGepKdEDUFeXRzDxPF1X/Btc8L+v7oqIQ==", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "eslint": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.16.0.tgz", + "integrity": "sha512-S3Rz11i7c8AA5JPv7xAH+dOyq/Cu/VXHiHXBPOU1k/JAM5dXqQPt3qcrhpHSorXmrpu2g0gkIBVXAqCpzfoZIg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.9.1", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^4.0.3", + "eslint-utils": "^1.3.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^5.0.1", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.7.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^6.2.2", + "js-yaml": "^3.13.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.11", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^5.5.1", + "strip-ansi": "^4.0.0", + "strip-json-comments": "^2.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0" + } + }, + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "espree": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-5.0.1.tgz", + "integrity": "sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A==", + "dev": true, + "requires": { + "acorn": "^6.0.7", + "acorn-jsx": "^5.0.0", + "eslint-visitor-keys": "^1.0.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "inquirer": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz", + "integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.12", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "prettier": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true + } + } + }, + "prettier-eslint-cli": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/prettier-eslint-cli/-/prettier-eslint-cli-5.0.0.tgz", + "integrity": "sha512-cei9UbN1aTrz3sQs88CWpvY/10PYTevzd76zoG1tdJ164OhmNTFRKPTOZrutVvscoQWzbnLKkviS3gu5JXwvZg==", + "dev": true, + "requires": { + "arrify": "^2.0.1", + "boolify": "^1.0.0", + "camelcase-keys": "^6.0.0", + "chalk": "^2.4.2", + "common-tags": "^1.8.0", + "core-js": "^3.1.4", + "eslint": "^5.0.0", + "find-up": "^4.1.0", + "get-stdin": "^7.0.0", + "glob": "^7.1.4", + "ignore": "^5.1.2", + "lodash.memoize": "^4.1.2", + "loglevel-colored-level-prefix": "^1.0.0", + "messageformat": "^2.2.1", + "prettier-eslint": "^9.0.0", + "rxjs": "^6.5.2", + "yargs": "^13.2.4" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha512-wFUFA5bg5dviipbQQ32yOQhl6gcJaJXiHE7dvR8VYPG97+J/GNC5FKGepKdEDUFeXRzDxPF1X/Btc8L+v7oqIQ==", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "eslint": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.16.0.tgz", + "integrity": "sha512-S3Rz11i7c8AA5JPv7xAH+dOyq/Cu/VXHiHXBPOU1k/JAM5dXqQPt3qcrhpHSorXmrpu2g0gkIBVXAqCpzfoZIg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.9.1", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^4.0.3", + "eslint-utils": "^1.3.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^5.0.1", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.7.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^6.2.2", + "js-yaml": "^3.13.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.11", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^5.5.1", + "strip-ansi": "^4.0.0", + "strip-json-comments": "^2.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0" + }, + "dependencies": { + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + } + } + }, + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "espree": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-5.0.1.tgz", + "integrity": "sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A==", + "dev": true, + "requires": { + "acorn": "^6.0.7", + "acorn-jsx": "^5.0.0", + "eslint-visitor-keys": "^1.0.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "get-stdin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz", + "integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==", + "dev": true + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + }, + "inquirer": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.2.tgz", + "integrity": "sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.2.0", + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^2.0.0", + "lodash": "^4.17.12", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.4.0", + "string-width": "^2.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true + } + } + }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha512-wFUFA5bg5dviipbQQ32yOQhl6gcJaJXiHE7dvR8VYPG97+J/GNC5FKGepKdEDUFeXRzDxPF1X/Btc8L+v7oqIQ==", + "dev": true + } + } + }, "pretty-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-4.0.0.tgz", @@ -1785,6 +3885,12 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, "prom-client": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.5.3.tgz", @@ -1849,6 +3955,18 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true + }, + "ramda": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.0.tgz", + "integrity": "sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA==", + "dev": true + }, "random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -1882,6 +4000,27 @@ "unpipe": "1.0.0" } }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha512-1orxQfbWGUiTn9XsPlChs6rLie/AV9jwZTGmu2NZw/CUDJQchXJFYE0Fq5j7+n558T1JhDWLdhyd1Zj+wLY//w==", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -1963,13 +4102,48 @@ "mkdirp": "~0.3.5" } }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ioredis": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.16.3.tgz", + "integrity": "sha512-Ejvcs2yW19Vq8AipvbtfcX3Ig8XG9EAyFOvGbhI/Q1QoVOK9ZdgY092kdOyOWIYBnPHjfjMJhU9qhsnp0i0K1w==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.1.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "redis-commands": "1.5.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.0.1" + } + }, "mkdirp": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz", "integrity": "sha512-8OCq0De/h9ZxseqzCH8Kw/Filf5pF/vMI6+BH7Lu0jXz2pqYCjTAQRolSxRIi+Ax+oCCjlxoJMP0YQ4XlrQNHg==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, + "regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true + }, "request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -2036,6 +4210,12 @@ } } }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, "require-in-the-middle": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-4.0.1.tgz", @@ -2067,6 +4247,18 @@ "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", "dev": true }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "require-relative": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", + "integrity": "sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==", + "dev": true + }, "resolve": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", @@ -2075,6 +4267,22 @@ "path-parse": "^1.0.6" } }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "retry-axios": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-0.3.2.tgz", @@ -2113,6 +4321,21 @@ "glob": "^6.0.1" } }, + "run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true + }, + "rxjs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", + "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, "safe-buffer": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", @@ -2196,6 +4419,12 @@ "send": "0.17.1" } }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true + }, "setprototypeof": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", @@ -2216,11 +4445,32 @@ } } }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + }, "shimmer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, "sinon": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/sinon/-/sinon-2.4.1.tgz", @@ -2260,6 +4510,25 @@ } } }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + } + } + }, "socket.io": { "version": "0.9.19", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-0.9.19.tgz", @@ -2306,6 +4575,38 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, "split": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", @@ -2314,6 +4615,12 @@ "through": "2" } }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", @@ -2355,6 +4662,48 @@ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -2363,6 +4712,81 @@ "safe-buffer": "~5.1.0" } }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz", + "integrity": "sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "table": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", + "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "lodash": "^4.17.14", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + } + } + }, "tdigest": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", @@ -2394,6 +4818,12 @@ "integrity": "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==", "dev": true }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -2418,6 +4848,15 @@ "resolved": "https://registry.npmjs.org/tinycolor/-/tinycolor-0.0.1.tgz", "integrity": "sha512-+CorETse1kl98xg0WAzii8DTT4ABF4R3nquhrkIbVGcw1T8JYs5Gfx9xEfGINPUZGDj9C4BmOtuKeaTtuuRolg==" }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, "toidentifier": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", @@ -2439,6 +4878,32 @@ } } }, + "tsconfig-paths": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", + "integrity": "sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==", + "dev": true, + "requires": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "tslib": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", + "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==", + "dev": true + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -2452,12 +4917,27 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, "type-detect": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", "integrity": "sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==", "dev": true }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2482,6 +4962,12 @@ } } }, + "typescript": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", + "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", + "dev": true + }, "uglify-js": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-1.2.5.tgz", @@ -2528,6 +5014,22 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.0.tgz", "integrity": "sha512-rqE1LoOVLv3QrZMjb4NkF5UWlkurCfPyItVnFPNKDDGkHw4dQUdE4zMcLqx28+0Kcf3+bnUk4PisaiRJT4aiaQ==" }, + "v8-compile-cache": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz", + "integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2543,11 +5045,151 @@ "extsprintf": "^1.2.0" } }, + "vue-eslint-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz", + "integrity": "sha512-ZezcU71Owm84xVF6gfurBQUGg8WQ+WZGxgDEQu1IHFBZNx7BFZg3L1yHxrCBNNwbwFtE1GuvfJKMtb6Xuwc/Bw==", + "dev": true, + "requires": { + "debug": "^3.1.0", + "eslint-scope": "^3.7.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^3.5.2", + "esquery": "^1.0.0", + "lodash": "^4.17.4" + }, + "dependencies": { + "acorn": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", + "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==", + "dev": true + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha512-AU7pnZkguthwBjKgCg6998ByQNIMjbuDQZ8bb78QAFZwPfmKia8AIzgY/gWgqCjnht8JLdXmB4YxA0KaV60ncQ==", + "dev": true, + "requires": { + "acorn": "^3.0.4" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha512-OLUyIIZ7mF5oaAUT1w0TFqQS81q3saT46x8t7ukpPjMNk+nbs4ZHhs7ToV8EWnLYLepjETXd4XaCE4uxkMeqUw==", + "dev": true + } + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "espree": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "dev": true, + "requires": { + "acorn": "^5.5.0", + "acorn-jsx": "^3.0.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==", + "dev": true + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, "ws": { "version": "0.4.32", "resolved": "https://registry.npmjs.org/ws/-/ws-0.4.32.tgz", @@ -2576,11 +5218,98 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.4.2.tgz", "integrity": "sha512-WTsthd44hTdCRrHkdtTgbgTKIJyNDV+xiShdooFZBUstY7xk+EXMx/u5gjuUXaCiCWvtBVCHwauzml2joevB4w==" }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + }, + "dependencies": { + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + } + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/services/real-time/package.json b/services/real-time/package.json index b6b2cfb8b1..5d9f58079e 100644 --- a/services/real-time/package.json +++ b/services/real-time/package.json @@ -8,17 +8,15 @@ "url": "https://github.com/sharelatex/real-time-sharelatex.git" }, "scripts": { - "compile:app": "([ -e app/coffee ] && coffee -m $COFFEE_OPTIONS -o app/js -c app/coffee || echo 'No CoffeeScript folder to compile') && ( [ -e app.coffee ] && coffee -m $COFFEE_OPTIONS -c app.coffee || echo 'No CoffeeScript app to compile')", - "start": "npm run compile:app && node $NODE_APP_OPTIONS app.js", - "test:acceptance:_run": "mocha --recursive --reporter spec --timeout 30000 --exit $@ test/acceptance/js", - "test:acceptance": "npm run compile:app && npm run compile:acceptance_tests && npm run test:acceptance:_run -- --grep=$MOCHA_GREP", - "test:unit:_run": "mocha --recursive --reporter spec --exit $@ test/unit/js", - "test:unit": "npm run compile:app && npm run compile:unit_tests && npm run test:unit:_run -- --grep=$MOCHA_GREP", - "compile:unit_tests": "[ ! -e test/unit/coffee ] && echo 'No unit tests to compile' || coffee -o test/unit/js -c test/unit/coffee", - "compile:acceptance_tests": "[ ! -e test/acceptance/coffee ] && echo 'No acceptance tests to compile' || coffee -o test/acceptance/js -c test/acceptance/coffee", - "compile:all": "npm run compile:app && npm run compile:unit_tests && npm run compile:acceptance_tests && npm run compile:smoke_tests", + "start": "node $NODE_APP_OPTIONS app.js", + "test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js", + "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", + "test:unit:_run": "mocha --recursive --reporter spec $@ test/unit/js", + "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", "nodemon": "nodemon --config nodemon.json", - "compile:smoke_tests": "[ ! -e test/smoke/coffee ] && echo 'No smoke tests to compile' || coffee -o test/smoke/js -c test/smoke/coffee" + "lint": "node_modules/.bin/eslint .", + "format": "node_modules/.bin/prettier-eslint $PWD'/**/*.js' --list-different", + "format:fix": "node_modules/.bin/prettier-eslint $PWD'/**/*.js' --write" }, "dependencies": { "async": "^0.9.0", @@ -41,10 +39,23 @@ "bunyan": "~0.22.3", "chai": "~1.9.1", "cookie-signature": "^1.1.0", + "eslint": "^6.8.0", + "eslint-config-prettier": "^6.10.0", + "eslint-config-standard": "^14.1.0", + "eslint-plugin-chai-expect": "^2.1.0", + "eslint-plugin-chai-friendly": "^0.5.0", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-mocha": "^6.3.0", + "eslint-plugin-node": "^11.0.0", + "eslint-plugin-prettier": "^3.1.2", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-standard": "^4.0.1", + "mocha": "^4.0.1", + "prettier": "^2.0.0", + "prettier-eslint-cli": "^5.0.0", "sandboxed-module": "~0.3.0", "sinon": "^2.4.1", - "mocha": "^4.0.1", - "uid-safe": "^2.1.5", - "timekeeper": "0.0.4" + "timekeeper": "0.0.4", + "uid-safe": "^2.1.5" } } diff --git a/services/real-time/socket.io.patch.js b/services/real-time/socket.io.patch.js index 354d8223c3..5852a4f266 100644 --- a/services/real-time/socket.io.patch.js +++ b/services/real-time/socket.io.patch.js @@ -1,51 +1,66 @@ // EventEmitter has been removed from process in node >= 7 // https://github.com/nodejs/node/commit/62b544290a075fe38e233887a06c408ba25a1c71 -if(process.versions.node.split('.')[0] >= 7) { +/* + A socket.io dependency expects the EventEmitter to be available at + `process.EventEmitter`. + See this trace: + --- + + /app/node_modules/policyfile/lib/server.js:254 + Object.keys(process.EventEmitter.prototype).forEach(function proxy (key){ + ^ + + TypeError: Cannot read property 'prototype' of undefined + at Object. (/app/node_modules/policyfile/lib/server.js:254:34) + */ +if (process.versions.node.split('.')[0] >= 7) { + // eslint-disable-next-line node/no-deprecated-api process.EventEmitter = require('events') } -var io = require("socket.io"); +var io = require('socket.io') +const logger = require('logger-sharelatex') -if (io.version === "0.9.16" || io.version === "0.9.19") { - console.log("patching socket.io hybi-16 transport frame prototype"); - var transports = require("socket.io/lib/transports/websocket/hybi-16.js"); - transports.prototype.frame = patchedFrameHandler; +if (io.version === '0.9.16' || io.version === '0.9.19') { + logger.warn('patching socket.io hybi-16 transport frame prototype') + var transports = require('socket.io/lib/transports/websocket/hybi-16.js') + transports.prototype.frame = patchedFrameHandler // file hybi-07-12 has the same problem but no browsers are using that protocol now } function patchedFrameHandler(opcode, str) { - var dataBuffer = new Buffer(str), - dataLength = dataBuffer.length, - startOffset = 2, - secondByte = dataLength; + var dataBuffer = Buffer.from(str) + var dataLength = dataBuffer.length + var startOffset = 2 + var secondByte = dataLength if (dataLength === 65536) { - console.log("fixing invalid frame length in socket.io"); + logger.log('fixing invalid frame length in socket.io') } if (dataLength > 65535) { // original code had > 65536 - startOffset = 10; - secondByte = 127; + startOffset = 10 + secondByte = 127 } else if (dataLength > 125) { - startOffset = 4; - secondByte = 126; + startOffset = 4 + secondByte = 126 } - var outputBuffer = new Buffer(dataLength + startOffset); - outputBuffer[0] = opcode; - outputBuffer[1] = secondByte; - dataBuffer.copy(outputBuffer, startOffset); + var outputBuffer = Buffer.alloc(dataLength + startOffset) + outputBuffer[0] = opcode + outputBuffer[1] = secondByte + dataBuffer.copy(outputBuffer, startOffset) switch (secondByte) { case 126: - outputBuffer[2] = dataLength >>> 8; - outputBuffer[3] = dataLength % 256; - break; + outputBuffer[2] = dataLength >>> 8 + outputBuffer[3] = dataLength % 256 + break case 127: - var l = dataLength; + var l = dataLength for (var i = 1; i <= 8; ++i) { - outputBuffer[startOffset - i] = l & 0xff; - l >>>= 8; + outputBuffer[startOffset - i] = l & 0xff + l >>>= 8 } } - return outputBuffer; + return outputBuffer } const parser = require('socket.io/lib/parser') diff --git a/services/real-time/test/acceptance/coffee/ApplyUpdateTests.coffee b/services/real-time/test/acceptance/coffee/ApplyUpdateTests.coffee deleted file mode 100644 index f2437f2641..0000000000 --- a/services/real-time/test/acceptance/coffee/ApplyUpdateTests.coffee +++ /dev/null @@ -1,222 +0,0 @@ -async = require "async" -chai = require("chai") -expect = chai.expect -chai.should() - -RealTimeClient = require "./helpers/RealTimeClient" -FixturesManager = require "./helpers/FixturesManager" - -settings = require "settings-sharelatex" -redis = require "redis-sharelatex" -rclient = redis.createClient(settings.redis.documentupdater) - -redisSettings = settings.redis - -describe "applyOtUpdate", -> - before -> - @update = { - op: [{i: "foo", p: 42}] - } - describe "when authorized", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, cb - - (cb) => - @client.emit "applyOtUpdate", @doc_id, @update, cb - ], done - - it "should push the doc into the pending updates list", (done) -> - rclient.lrange "pending-updates-list", 0, -1, (error, [doc_id]) => - doc_id.should.equal "#{@project_id}:#{@doc_id}" - done() - return null - - it "should push the update into redis", (done) -> - rclient.lrange redisSettings.documentupdater.key_schema.pendingUpdates({@doc_id}), 0, -1, (error, [update]) => - update = JSON.parse(update) - update.op.should.deep.equal @update.op - update.meta.should.deep.equal { - source: @client.publicId - user_id: @user_id - } - done() - return null - - after (done) -> - async.series [ - (cb) => rclient.del "pending-updates-list", cb - (cb) => rclient.del "DocsWithPendingUpdates", "#{@project_id}:#{@doc_id}", cb - (cb) => rclient.del redisSettings.documentupdater.key_schema.pendingUpdates(@doc_id), cb - ], done - - describe "when authorized with a huge edit update", -> - before (done) -> - @update = { - op: { - p: 12, - t: "update is too large".repeat(1024 * 400) # >7MB - } - } - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - @client.on "otUpdateError", (@otUpdateError) => - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, cb - - (cb) => - @client.emit "applyOtUpdate", @doc_id, @update, (@error) => - cb() - ], done - - it "should not return an error", -> - expect(@error).to.not.exist - - it "should send an otUpdateError to the client", (done) -> - setTimeout () => - expect(@otUpdateError).to.exist - done() - , 300 - - it "should disconnect the client", (done) -> - setTimeout () => - @client.socket.connected.should.equal false - done() - , 300 - - it "should not put the update in redis", (done) -> - rclient.llen redisSettings.documentupdater.key_schema.pendingUpdates({@doc_id}), (error, len) => - len.should.equal 0 - done() - return null - - describe "when authorized to read-only with an edit update", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readOnly" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, cb - - (cb) => - @client.emit "applyOtUpdate", @doc_id, @update, (@error) => - cb() - ], done - - it "should return an error", -> - expect(@error).to.exist - - it "should disconnect the client", (done) -> - setTimeout () => - @client.socket.connected.should.equal false - done() - , 300 - - it "should not put the update in redis", (done) -> - rclient.llen redisSettings.documentupdater.key_schema.pendingUpdates({@doc_id}), (error, len) => - len.should.equal 0 - done() - return null - - describe "when authorized to read-only with a comment update", -> - before (done) -> - @comment_update = { - op: [{c: "foo", p: 42}] - } - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readOnly" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, cb - - (cb) => - @client.emit "applyOtUpdate", @doc_id, @comment_update, cb - ], done - - it "should push the doc into the pending updates list", (done) -> - rclient.lrange "pending-updates-list", 0, -1, (error, [doc_id]) => - doc_id.should.equal "#{@project_id}:#{@doc_id}" - done() - return null - - it "should push the update into redis", (done) -> - rclient.lrange redisSettings.documentupdater.key_schema.pendingUpdates({@doc_id}), 0, -1, (error, [update]) => - update = JSON.parse(update) - update.op.should.deep.equal @comment_update.op - update.meta.should.deep.equal { - source: @client.publicId - user_id: @user_id - } - done() - return null - - after (done) -> - async.series [ - (cb) => rclient.del "pending-updates-list", cb - (cb) => rclient.del "DocsWithPendingUpdates", "#{@project_id}:#{@doc_id}", cb - (cb) => rclient.del redisSettings.documentupdater.key_schema.pendingUpdates({@doc_id}), cb - ], done diff --git a/services/real-time/test/acceptance/coffee/ClientTrackingTests.coffee b/services/real-time/test/acceptance/coffee/ClientTrackingTests.coffee deleted file mode 100644 index e76eca7d8e..0000000000 --- a/services/real-time/test/acceptance/coffee/ClientTrackingTests.coffee +++ /dev/null @@ -1,146 +0,0 @@ -chai = require("chai") -expect = chai.expect -chai.should() - -RealTimeClient = require "./helpers/RealTimeClient" -MockWebServer = require "./helpers/MockWebServer" -FixturesManager = require "./helpers/FixturesManager" - -async = require "async" - -describe "clientTracking", -> - describe "when a client updates its cursor location", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { name: "Test Project" } - }, (error, {@user_id, @project_id}) => cb() - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientB = RealTimeClient.connect() - @clientB.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", { - project_id: @project_id - }, cb - - (cb) => - @clientA.emit "joinDoc", @doc_id, cb - - (cb) => - @clientB.emit "joinProject", { - project_id: @project_id - }, cb - - (cb) => - @updates = [] - @clientB.on "clientTracking.clientUpdated", (data) => - @updates.push data - - @clientA.emit "clientTracking.updatePosition", { - row: @row = 42 - column: @column = 36 - doc_id: @doc_id - }, (error) -> - throw error if error? - setTimeout cb, 300 # Give the message a chance to reach client B. - ], done - - it "should tell other clients about the update", -> - @updates.should.deep.equal [ - { - row: @row - column: @column - doc_id: @doc_id - id: @clientA.publicId - user_id: @user_id - name: "Joe Bloggs" - } - ] - - it "should record the update in getConnectedUsers", (done) -> - @clientB.emit "clientTracking.getConnectedUsers", (error, users) => - for user in users - if user.client_id == @clientA.publicId - expect(user.cursorData).to.deep.equal({ - row: @row - column: @column - doc_id: @doc_id - }) - return done() - throw new Error("user was never found") - - describe "when an anonymous client updates its cursor location", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { name: "Test Project" } - publicAccess: "readAndWrite" - }, (error, {@user_id, @project_id}) => cb() - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", { - project_id: @project_id - }, cb - - (cb) => - RealTimeClient.setSession({}, cb) - - (cb) => - @anonymous = RealTimeClient.connect() - @anonymous.on "connectionAccepted", cb - - (cb) => - @anonymous.emit "joinProject", { - project_id: @project_id - }, cb - - (cb) => - @anonymous.emit "joinDoc", @doc_id, cb - - (cb) => - @updates = [] - @clientA.on "clientTracking.clientUpdated", (data) => - @updates.push data - - @anonymous.emit "clientTracking.updatePosition", { - row: @row = 42 - column: @column = 36 - doc_id: @doc_id - }, (error) -> - throw error if error? - setTimeout cb, 300 # Give the message a chance to reach client B. - ], done - - it "should tell other clients about the update", -> - @updates.should.deep.equal [ - { - row: @row - column: @column - doc_id: @doc_id - id: @anonymous.publicId - user_id: "anonymous-user" - name: "" - } - ] diff --git a/services/real-time/test/acceptance/coffee/DrainManagerTests.coffee b/services/real-time/test/acceptance/coffee/DrainManagerTests.coffee deleted file mode 100644 index b5b192cf88..0000000000 --- a/services/real-time/test/acceptance/coffee/DrainManagerTests.coffee +++ /dev/null @@ -1,82 +0,0 @@ -RealTimeClient = require "./helpers/RealTimeClient" -FixturesManager = require "./helpers/FixturesManager" - -expect = require("chai").expect - -async = require "async" -request = require "request" - -Settings = require "settings-sharelatex" - -drain = (rate, callback) -> - request.post { - url: "http://localhost:3026/drain?rate=#{rate}" - auth: { - user: Settings.internal.realTime.user, - pass: Settings.internal.realTime.pass - } - }, (error, response, data) -> - callback error, data - return null - -describe "DrainManagerTests", -> - before (done) -> - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => done() - return null - - before (done) -> - # cleanup to speedup reconnecting - @timeout(10000) - RealTimeClient.disconnectAllClients done - - # trigger and check cleanup - it "should have disconnected all previous clients", (done) -> - RealTimeClient.getConnectedClients (error, data) -> - return done(error) if error - expect(data.length).to.equal(0) - done() - - describe "with two clients in the project", -> - beforeEach (done) -> - async.series [ - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientB = RealTimeClient.connect() - @clientB.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, cb - - (cb) => - @clientB.emit "joinProject", project_id: @project_id, cb - ], done - - describe "starting to drain", () -> - beforeEach (done) -> - async.parallel [ - (cb) => - @clientA.on "reconnectGracefully", cb - (cb) => - @clientB.on "reconnectGracefully", cb - - (cb) -> drain(2, cb) - ], done - - afterEach (done) -> - # reset drain - drain(0, done) - - it "should not timeout", -> - expect(true).to.equal(true) - - it "should not have disconnected", -> - expect(@clientA.socket.connected).to.equal true - expect(@clientB.socket.connected).to.equal true diff --git a/services/real-time/test/acceptance/coffee/EarlyDisconnect.coffee b/services/real-time/test/acceptance/coffee/EarlyDisconnect.coffee deleted file mode 100644 index d90c36b430..0000000000 --- a/services/real-time/test/acceptance/coffee/EarlyDisconnect.coffee +++ /dev/null @@ -1,160 +0,0 @@ -async = require "async" -{expect} = require("chai") - -RealTimeClient = require "./helpers/RealTimeClient" -MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer" -MockWebServer = require "./helpers/MockWebServer" -FixturesManager = require "./helpers/FixturesManager" - -settings = require "settings-sharelatex" -redis = require "redis-sharelatex" -rclient = redis.createClient(settings.redis.pubsub) -rclientRT = redis.createClient(settings.redis.realtime) -KeysRT = settings.redis.realtime.key_schema - -describe "EarlyDisconnect", -> - before (done) -> - MockDocUpdaterServer.run done - - describe "when the client disconnects before joinProject completes", -> - before () -> - # slow down web-api requests to force the race condition - @actualWebAPIjoinProject = joinProject = MockWebServer.joinProject - MockWebServer.joinProject = (project_id, user_id, cb) -> - setTimeout () -> - joinProject(project_id, user_id, cb) - , 300 - - after () -> - MockWebServer.joinProject = @actualWebAPIjoinProject - - beforeEach (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (() ->) - # disconnect before joinProject completes - @clientA.on "disconnect", () -> cb() - @clientA.disconnect() - - (cb) => - # wait for joinDoc and subscribe - setTimeout cb, 500 - ], done - - # we can force the race condition, there is no need to repeat too often - for attempt in Array.from(length: 5).map((_, i) -> i+1) - it "should not subscribe to the pub/sub channel anymore (race #{attempt})", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - expect(resp).to.not.include "editor-events:#{@project_id}" - done() - return null - - describe "when the client disconnects before joinDoc completes", -> - beforeEach (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA.emit "joinDoc", @doc_id, (() ->) - # disconnect before joinDoc completes - @clientA.on "disconnect", () -> cb() - @clientA.disconnect() - - (cb) => - # wait for subscribe and unsubscribe - setTimeout cb, 100 - ], done - - # we can not force the race condition, so we have to try many times - for attempt in Array.from(length: 20).map((_, i) -> i+1) - it "should not subscribe to the pub/sub channels anymore (race #{attempt})", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - expect(resp).to.not.include "editor-events:#{@project_id}" - - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - expect(resp).to.not.include "applied-ops:#{@doc_id}" - done() - return null - - describe "when the client disconnects before clientTracking.updatePosition starts", -> - beforeEach (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA.emit "joinDoc", @doc_id, cb - - (cb) => - @clientA.emit "clientTracking.updatePosition", { - row: 42 - column: 36 - doc_id: @doc_id - }, (() ->) - # disconnect before updateClientPosition completes - @clientA.on "disconnect", () -> cb() - @clientA.disconnect() - - (cb) => - # wait for updateClientPosition - setTimeout cb, 100 - ], done - - # we can not force the race condition, so we have to try many times - for attempt in Array.from(length: 20).map((_, i) -> i+1) - it "should not show the client as connected (race #{attempt})", (done) -> - rclientRT.smembers KeysRT.clientsInProject({project_id: @project_id}), (err, results) -> - return done(err) if err - expect(results).to.deep.equal([]) - done() - return null diff --git a/services/real-time/test/acceptance/coffee/HttpControllerTests.coffee b/services/real-time/test/acceptance/coffee/HttpControllerTests.coffee deleted file mode 100644 index 524ea7e5de..0000000000 --- a/services/real-time/test/acceptance/coffee/HttpControllerTests.coffee +++ /dev/null @@ -1,68 +0,0 @@ -async = require('async') -expect = require('chai').expect -request = require('request').defaults({ - baseUrl: 'http://localhost:3026' -}) - -RealTimeClient = require "./helpers/RealTimeClient" -FixturesManager = require "./helpers/FixturesManager" - -describe 'HttpControllerTests', -> - describe 'without a user', -> - it 'should return 404 for the client view', (done) -> - client_id = 'not-existing' - request.get { - url: "/clients/#{client_id}" - json: true - }, (error, response, data) -> - return done(error) if error - expect(response.statusCode).to.equal(404) - done() - - describe 'with a user and after joining a project', -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - }, (error, {@project_id, @user_id}) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {}, (error, {@doc_id}) => - cb(error) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", {@project_id}, cb - - (cb) => - @client.emit "joinDoc", @doc_id, cb - ], done - - it 'should send a client view', (done) -> - request.get { - url: "/clients/#{@client.socket.sessionid}" - json: true - }, (error, response, data) => - return done(error) if error - expect(response.statusCode).to.equal(200) - expect(data.connected_time).to.exist - delete data.connected_time - # .email is not set in the session - delete data.email - expect(data).to.deep.equal({ - client_id: @client.socket.sessionid, - first_name: 'Joe', - last_name: 'Bloggs', - project_id: @project_id, - user_id: @user_id, - rooms: [ - @project_id, - @doc_id, - ] - }) - done() diff --git a/services/real-time/test/acceptance/coffee/JoinDocTests.coffee b/services/real-time/test/acceptance/coffee/JoinDocTests.coffee deleted file mode 100644 index 6c204b6079..0000000000 --- a/services/real-time/test/acceptance/coffee/JoinDocTests.coffee +++ /dev/null @@ -1,246 +0,0 @@ -chai = require("chai") -expect = chai.expect -chai.should() - -RealTimeClient = require "./helpers/RealTimeClient" -MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer" -FixturesManager = require "./helpers/FixturesManager" - -async = require "async" - -describe "joinDoc", -> - before -> - @lines = ["test", "doc", "lines"] - @version = 42 - @ops = ["mock", "doc", "ops"] - @ranges = {"mock": "ranges"} - - describe "when authorised readAndWrite", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops, @ranges}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, (error, @returnedArgs...) => cb(error) - ], done - - it "should get the doc from the doc updater", -> - MockDocUpdaterServer.getDocument - .calledWith(@project_id, @doc_id, -1) - .should.equal true - - it "should return the doc lines, version, ranges and ops", -> - @returnedArgs.should.deep.equal [@lines, @version, @ops, @ranges] - - it "should have joined the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal true - done() - - describe "when authorised readOnly", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readOnly" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops, @ranges}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, (error, @returnedArgs...) => cb(error) - ], done - - it "should get the doc from the doc updater", -> - MockDocUpdaterServer.getDocument - .calledWith(@project_id, @doc_id, -1) - .should.equal true - - it "should return the doc lines, version, ranges and ops", -> - @returnedArgs.should.deep.equal [@lines, @version, @ops, @ranges] - - it "should have joined the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal true - done() - - describe "when authorised as owner", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops, @ranges}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, (error, @returnedArgs...) => cb(error) - ], done - - it "should get the doc from the doc updater", -> - MockDocUpdaterServer.getDocument - .calledWith(@project_id, @doc_id, -1) - .should.equal true - - it "should return the doc lines, version, ranges and ops", -> - @returnedArgs.should.deep.equal [@lines, @version, @ops, @ranges] - - it "should have joined the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal true - done() - - # It is impossible to write an acceptance test to test joining an unauthorized - # project, since joinProject already catches that. If you can join a project, - # then you can join a doc in that project. - - describe "with a fromVersion", -> - before (done) -> - @fromVersion = 36 - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops, @ranges}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, @fromVersion, (error, @returnedArgs...) => cb(error) - ], done - - it "should get the doc from the doc updater with the fromVersion", -> - MockDocUpdaterServer.getDocument - .calledWith(@project_id, @doc_id, @fromVersion) - .should.equal true - - it "should return the doc lines, version, ranges and ops", -> - @returnedArgs.should.deep.equal [@lines, @version, @ops, @ranges] - - it "should have joined the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal true - done() - - describe "with options", -> - before (done) -> - @options = { encodeRanges: true } - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops, @ranges}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, @options, (error, @returnedArgs...) => cb(error) - ], done - - it "should get the doc from the doc updater with the default fromVersion", -> - MockDocUpdaterServer.getDocument - .calledWith(@project_id, @doc_id, -1) - .should.equal true - - it "should return the doc lines, version, ranges and ops", -> - @returnedArgs.should.deep.equal [@lines, @version, @ops, @ranges] - - it "should have joined the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal true - done() - - describe "with fromVersion and options", -> - before (done) -> - @fromVersion = 36 - @options = { encodeRanges: true } - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops, @ranges}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, @fromVersion, @options, (error, @returnedArgs...) => cb(error) - ], done - - it "should get the doc from the doc updater with the fromVersion", -> - MockDocUpdaterServer.getDocument - .calledWith(@project_id, @doc_id, @fromVersion) - .should.equal true - - it "should return the doc lines, version, ranges and ops", -> - @returnedArgs.should.deep.equal [@lines, @version, @ops, @ranges] - - it "should have joined the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal true - done() diff --git a/services/real-time/test/acceptance/coffee/JoinProjectTests.coffee b/services/real-time/test/acceptance/coffee/JoinProjectTests.coffee deleted file mode 100644 index 11082cdb6c..0000000000 --- a/services/real-time/test/acceptance/coffee/JoinProjectTests.coffee +++ /dev/null @@ -1,108 +0,0 @@ -chai = require("chai") -expect = chai.expect -chai.should() - -RealTimeClient = require "./helpers/RealTimeClient" -MockWebServer = require "./helpers/MockWebServer" -FixturesManager = require "./helpers/FixturesManager" - -async = require "async" - -describe "joinProject", -> - describe "when authorized", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - ], done - - it "should get the project from web", -> - MockWebServer.joinProject - .calledWith(@project_id, @user_id) - .should.equal true - - it "should return the project", -> - @project.should.deep.equal { - name: "Test Project" - } - - it "should return the privilege level", -> - @privilegeLevel.should.equal "owner" - - it "should return the protocolVersion", -> - @protocolVersion.should.equal 2 - - it "should have joined the project room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@project_id in client.rooms).to.equal true - done() - - it "should have marked the user as connected", (done) -> - @client.emit "clientTracking.getConnectedUsers", (error, users) => - connected = false - for user in users - if user.client_id == @client.publicId and user.user_id == @user_id - connected = true - break - expect(connected).to.equal true - done() - - describe "when not authorized", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: null - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, (@error, @project, @privilegeLevel, @protocolVersion) => - cb() - ], done - - it "should return an error", -> - @error.message.should.equal "not authorized" - - it "should not have joined the project room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@project_id in client.rooms).to.equal false - done() - - describe "when over rate limit", -> - before (done) -> - async.series [ - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: 'rate-limited', (@error) => - cb() - ], done - - it "should return a TooManyRequests error code", -> - @error.message.should.equal "rate-limit hit when joining project" - @error.code.should.equal "TooManyRequests" - diff --git a/services/real-time/test/acceptance/coffee/LeaveDocTests.coffee b/services/real-time/test/acceptance/coffee/LeaveDocTests.coffee deleted file mode 100644 index e35e9093d3..0000000000 --- a/services/real-time/test/acceptance/coffee/LeaveDocTests.coffee +++ /dev/null @@ -1,86 +0,0 @@ -chai = require("chai") -expect = chai.expect -chai.should() -sinon = require("sinon") - -RealTimeClient = require "./helpers/RealTimeClient" -MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer" -FixturesManager = require "./helpers/FixturesManager" -logger = require("logger-sharelatex") - -async = require "async" - -describe "leaveDoc", -> - before -> - @lines = ["test", "doc", "lines"] - @version = 42 - @ops = ["mock", "doc", "ops"] - sinon.spy(logger, "error") - sinon.spy(logger, "warn") - sinon.spy(logger, "log") - @other_doc_id = FixturesManager.getRandomId() - - after -> - logger.error.restore() # remove the spy - logger.warn.restore() - logger.log.restore() - - describe "when joined to a doc", -> - beforeEach (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "readAndWrite" - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id, cb - - (cb) => - @client.emit "joinDoc", @doc_id, (error, @returnedArgs...) => cb(error) - ], done - - describe "then leaving the doc", -> - beforeEach (done) -> - @client.emit "leaveDoc", @doc_id, (error) -> - throw error if error? - done() - - it "should have left the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal false - done() - - describe "when sending a leaveDoc request before the previous joinDoc request has completed", -> - beforeEach (done) -> - @client.emit "leaveDoc", @doc_id, () -> - @client.emit "joinDoc", @doc_id, () -> - @client.emit "leaveDoc", @doc_id, (error) -> - throw error if error? - done() - - it "should not trigger an error", -> - sinon.assert.neverCalledWith(logger.error, sinon.match.any, "not subscribed - shouldn't happen") - - it "should have left the doc room", (done) -> - RealTimeClient.getConnectedClient @client.socket.sessionid, (error, client) => - expect(@doc_id in client.rooms).to.equal false - done() - - describe "when sending a leaveDoc for a room the client has not joined ", -> - beforeEach (done) -> - @client.emit "leaveDoc", @other_doc_id, (error) -> - throw error if error? - done() - - it "should trigger a low level message only", -> - sinon.assert.calledWith(logger.log, sinon.match.any, "ignoring request from client to leave room it is not in") diff --git a/services/real-time/test/acceptance/coffee/LeaveProjectTests.coffee b/services/real-time/test/acceptance/coffee/LeaveProjectTests.coffee deleted file mode 100644 index 91ec1a1159..0000000000 --- a/services/real-time/test/acceptance/coffee/LeaveProjectTests.coffee +++ /dev/null @@ -1,147 +0,0 @@ -RealTimeClient = require "./helpers/RealTimeClient" -MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer" -FixturesManager = require "./helpers/FixturesManager" - -async = require "async" - -settings = require "settings-sharelatex" -redis = require "redis-sharelatex" -rclient = redis.createClient(settings.redis.pubsub) - -describe "leaveProject", -> - before (done) -> - MockDocUpdaterServer.run done - - describe "with other clients in the project", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientB = RealTimeClient.connect() - @clientB.on "connectionAccepted", cb - - @clientBDisconnectMessages = [] - @clientB.on "clientTracking.clientDisconnected", (data) => - @clientBDisconnectMessages.push data - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - @clientB.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA.emit "joinDoc", @doc_id, cb - (cb) => - @clientB.emit "joinDoc", @doc_id, cb - - (cb) => - # leaveProject is called when the client disconnects - @clientA.on "disconnect", () -> cb() - @clientA.disconnect() - - (cb) => - # The API waits a little while before flushing changes - setTimeout done, 1000 - - ], done - - it "should emit a disconnect message to the room", -> - @clientBDisconnectMessages.should.deep.equal [@clientA.publicId] - - it "should no longer list the client in connected users", (done) -> - @clientB.emit "clientTracking.getConnectedUsers", (error, users) => - for user in users - if user.client_id == @clientA.publicId - throw "Expected clientA to not be listed in connected users" - return done() - - it "should not flush the project to the document updater", -> - MockDocUpdaterServer.deleteProject - .calledWith(@project_id) - .should.equal false - - it "should remain subscribed to the editor-events channels", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.include "editor-events:#{@project_id}" - done() - return null - - it "should remain subscribed to the applied-ops channels", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.include "applied-ops:#{@doc_id}" - done() - return null - - describe "with no other clients in the project", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connect", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - (cb) => - @clientA.emit "joinDoc", @doc_id, cb - - (cb) => - # leaveProject is called when the client disconnects - @clientA.on "disconnect", () -> cb() - @clientA.disconnect() - - (cb) => - # The API waits a little while before flushing changes - setTimeout done, 1000 - ], done - - it "should flush the project to the document updater", -> - MockDocUpdaterServer.deleteProject - .calledWith(@project_id) - .should.equal true - - it "should not subscribe to the editor-events channels anymore", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.not.include "editor-events:#{@project_id}" - done() - return null - - it "should not subscribe to the applied-ops channels anymore", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.not.include "applied-ops:#{@doc_id}" - done() - return null diff --git a/services/real-time/test/acceptance/coffee/PubSubRace.coffee b/services/real-time/test/acceptance/coffee/PubSubRace.coffee deleted file mode 100644 index d5e6653fac..0000000000 --- a/services/real-time/test/acceptance/coffee/PubSubRace.coffee +++ /dev/null @@ -1,205 +0,0 @@ -RealTimeClient = require "./helpers/RealTimeClient" -MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer" -FixturesManager = require "./helpers/FixturesManager" - -async = require "async" - -settings = require "settings-sharelatex" -redis = require "redis-sharelatex" -rclient = redis.createClient(settings.redis.pubsub) - -describe "PubSubRace", -> - before (done) -> - MockDocUpdaterServer.run done - - describe "when the client leaves a doc before joinDoc completes", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connect", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA.emit "joinDoc", @doc_id, () -> - # leave before joinDoc completes - @clientA.emit "leaveDoc", @doc_id, cb - - (cb) => - # wait for subscribe and unsubscribe - setTimeout cb, 100 - ], done - - it "should not subscribe to the applied-ops channels anymore", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.not.include "applied-ops:#{@doc_id}" - done() - return null - - describe "when the client emits joinDoc and leaveDoc requests frequently and leaves eventually", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connect", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, cb - - (cb) => - # wait for subscribe and unsubscribe - setTimeout cb, 100 - ], done - - it "should not subscribe to the applied-ops channels anymore", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.not.include "applied-ops:#{@doc_id}" - done() - return null - - describe "when the client emits joinDoc and leaveDoc requests frequently and remains in the doc", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connect", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, () -> - @clientA.emit "leaveDoc", @doc_id, () -> - @clientA.emit "joinDoc", @doc_id, cb - - (cb) => - # wait for subscribe and unsubscribe - setTimeout cb, 100 - ], done - - it "should subscribe to the applied-ops channels", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.include "applied-ops:#{@doc_id}" - done() - return null - - describe "when the client disconnects before joinDoc completes", -> - before (done) -> - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => cb() - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connect", cb - - (cb) => - @clientA.emit "joinProject", project_id: @project_id, (error, @project, @privilegeLevel, @protocolVersion) => - cb(error) - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - joinDocCompleted = false - @clientA.emit "joinDoc", @doc_id, () -> - joinDocCompleted = true - # leave before joinDoc completes - setTimeout () => - if joinDocCompleted - return cb(new Error('joinDocCompleted -- lower timeout')) - @clientA.on "disconnect", () -> cb() - @clientA.disconnect() - # socket.io processes joinDoc and disconnect with different delays: - # - joinDoc goes through two process.nextTick - # - disconnect goes through one process.nextTick - # We have to inject the disconnect event into a different event loop - # cycle. - , 3 - - (cb) => - # wait for subscribe and unsubscribe - setTimeout cb, 100 - ], done - - it "should not subscribe to the editor-events channels anymore", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.not.include "editor-events:#{@project_id}" - done() - return null - - it "should not subscribe to the applied-ops channels anymore", (done) -> - rclient.pubsub 'CHANNELS', (err, resp) => - return done(err) if err - resp.should.not.include "applied-ops:#{@doc_id}" - done() - return null diff --git a/services/real-time/test/acceptance/coffee/ReceiveUpdateTests.coffee b/services/real-time/test/acceptance/coffee/ReceiveUpdateTests.coffee deleted file mode 100644 index da9ee0ca36..0000000000 --- a/services/real-time/test/acceptance/coffee/ReceiveUpdateTests.coffee +++ /dev/null @@ -1,208 +0,0 @@ -chai = require("chai") -expect = chai.expect -chai.should() - -RealTimeClient = require "./helpers/RealTimeClient" -MockWebServer = require "./helpers/MockWebServer" -FixturesManager = require "./helpers/FixturesManager" - -async = require "async" - -settings = require "settings-sharelatex" -redis = require "redis-sharelatex" -rclient = redis.createClient(settings.redis.pubsub) - -describe "receiveUpdate", -> - beforeEach (done) -> - @lines = ["test", "doc", "lines"] - @version = 42 - @ops = ["mock", "doc", "ops"] - - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { name: "Test Project" } - }, (error, {@user_id, @project_id}) => cb() - - (cb) => - FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (e, {@doc_id}) => - cb(e) - - (cb) => - @clientA = RealTimeClient.connect() - @clientA.on "connectionAccepted", cb - - (cb) => - @clientB = RealTimeClient.connect() - @clientB.on "connectionAccepted", cb - - (cb) => - @clientA.emit "joinProject", { - project_id: @project_id - }, cb - - (cb) => - @clientA.emit "joinDoc", @doc_id, cb - - (cb) => - @clientB.emit "joinProject", { - project_id: @project_id - }, cb - - (cb) => - @clientB.emit "joinDoc", @doc_id, cb - - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: {name: "Test Project"} - }, (error, {user_id: @user_id_second, project_id: @project_id_second}) => cb() - - (cb) => - FixturesManager.setUpDoc @project_id_second, {@lines, @version, @ops}, (e, {doc_id: @doc_id_second}) => - cb(e) - - (cb) => - @clientC = RealTimeClient.connect() - @clientC.on "connectionAccepted", cb - - (cb) => - @clientC.emit "joinProject", { - project_id: @project_id_second - }, cb - (cb) => - @clientC.emit "joinDoc", @doc_id_second, cb - - (cb) => - @clientAUpdates = [] - @clientA.on "otUpdateApplied", (update) => @clientAUpdates.push(update) - @clientBUpdates = [] - @clientB.on "otUpdateApplied", (update) => @clientBUpdates.push(update) - @clientCUpdates = [] - @clientC.on "otUpdateApplied", (update) => @clientCUpdates.push(update) - - @clientAErrors = [] - @clientA.on "otUpdateError", (error) => @clientAErrors.push(error) - @clientBErrors = [] - @clientB.on "otUpdateError", (error) => @clientBErrors.push(error) - @clientCErrors = [] - @clientC.on "otUpdateError", (error) => @clientCErrors.push(error) - cb() - ], done - - afterEach () -> - @clientA?.disconnect() - @clientB?.disconnect() - @clientC?.disconnect() - - describe "with an update from clientA", -> - beforeEach (done) -> - @update = { - doc_id: @doc_id - op: - meta: - source: @clientA.publicId - v: @version - doc: @doc_id - op: [{i: "foo", p: 50}] - } - rclient.publish "applied-ops", JSON.stringify(@update) - setTimeout done, 200 # Give clients time to get message - - it "should send the full op to clientB", -> - @clientBUpdates.should.deep.equal [@update.op] - - it "should send an ack to clientA", -> - @clientAUpdates.should.deep.equal [{ - v: @version, doc: @doc_id - }] - - it "should send nothing to clientC", -> - @clientCUpdates.should.deep.equal [] - - describe "with an update from clientC", -> - beforeEach (done) -> - @update = { - doc_id: @doc_id_second - op: - meta: - source: @clientC.publicId - v: @version - doc: @doc_id_second - op: [{i: "update from clientC", p: 50}] - } - rclient.publish "applied-ops", JSON.stringify(@update) - setTimeout done, 200 # Give clients time to get message - - it "should send nothing to clientA", -> - @clientAUpdates.should.deep.equal [] - - it "should send nothing to clientB", -> - @clientBUpdates.should.deep.equal [] - - it "should send an ack to clientC", -> - @clientCUpdates.should.deep.equal [{ - v: @version, doc: @doc_id_second - }] - - describe "with an update from a remote client for project 1", -> - beforeEach (done) -> - @update = { - doc_id: @doc_id - op: - meta: - source: 'this-is-a-remote-client-id' - v: @version - doc: @doc_id - op: [{i: "foo", p: 50}] - } - rclient.publish "applied-ops", JSON.stringify(@update) - setTimeout done, 200 # Give clients time to get message - - it "should send the full op to clientA", -> - @clientAUpdates.should.deep.equal [@update.op] - - it "should send the full op to clientB", -> - @clientBUpdates.should.deep.equal [@update.op] - - it "should send nothing to clientC", -> - @clientCUpdates.should.deep.equal [] - - describe "with an error for the first project", -> - beforeEach (done) -> - rclient.publish "applied-ops", JSON.stringify({doc_id: @doc_id, error: @error = "something went wrong"}) - setTimeout done, 200 # Give clients time to get message - - it "should send the error to the clients in the first project", -> - @clientAErrors.should.deep.equal [@error] - @clientBErrors.should.deep.equal [@error] - - it "should not send any errors to the client in the second project", -> - @clientCErrors.should.deep.equal [] - - it "should disconnect the clients of the first project", -> - @clientA.socket.connected.should.equal false - @clientB.socket.connected.should.equal false - - it "should not disconnect the client in the second project", -> - @clientC.socket.connected.should.equal true - - describe "with an error for the second project", -> - beforeEach (done) -> - rclient.publish "applied-ops", JSON.stringify({doc_id: @doc_id_second, error: @error = "something went wrong"}) - setTimeout done, 200 # Give clients time to get message - - it "should not send any errors to the clients in the first project", -> - @clientAErrors.should.deep.equal [] - @clientBErrors.should.deep.equal [] - - it "should send the error to the client in the second project", -> - @clientCErrors.should.deep.equal [@error] - - it "should not disconnect the clients of the first project", -> - @clientA.socket.connected.should.equal true - @clientB.socket.connected.should.equal true - - it "should disconnect the client in the second project", -> - @clientC.socket.connected.should.equal false diff --git a/services/real-time/test/acceptance/coffee/RouterTests.coffee b/services/real-time/test/acceptance/coffee/RouterTests.coffee deleted file mode 100644 index c3952a2887..0000000000 --- a/services/real-time/test/acceptance/coffee/RouterTests.coffee +++ /dev/null @@ -1,76 +0,0 @@ -async = require "async" -{expect} = require("chai") - -RealTimeClient = require "./helpers/RealTimeClient" -FixturesManager = require "./helpers/FixturesManager" - - -describe "Router", -> - describe "joinProject", -> - describe "when there is no callback provided", -> - after () -> - process.removeListener('unhandledRejection', @onUnhandled) - - before (done) -> - @onUnhandled = (error) -> - done(error) - process.on('unhandledRejection', @onUnhandled) - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", project_id: @project_id - setTimeout(cb, 100) - ], done - - it "should keep on going", -> - expect('still running').to.exist - - describe "when there are too many arguments", -> - after () -> - process.removeListener('unhandledRejection', @onUnhandled) - - before (done) -> - @onUnhandled = (error) -> - done(error) - process.on('unhandledRejection', @onUnhandled) - async.series [ - (cb) => - FixturesManager.setUpProject { - privilegeLevel: "owner" - project: { - name: "Test Project" - } - }, (e, {@project_id, @user_id}) => - cb(e) - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client = RealTimeClient.connect() - @client.on "connectionAccepted", cb - - (cb) => - @client.emit "joinProject", 1, 2, 3, 4, 5, (@error) => - cb() - ], done - - it "should return an error message", -> - expect(@error.message).to.equal('unexpected arguments') diff --git a/services/real-time/test/acceptance/coffee/SessionSocketsTests.coffee b/services/real-time/test/acceptance/coffee/SessionSocketsTests.coffee deleted file mode 100644 index 3009da682f..0000000000 --- a/services/real-time/test/acceptance/coffee/SessionSocketsTests.coffee +++ /dev/null @@ -1,67 +0,0 @@ -RealTimeClient = require("./helpers/RealTimeClient") -Settings = require("settings-sharelatex") -{expect} = require('chai') - -describe 'SessionSockets', -> - before -> - @checkSocket = (fn) -> - client = RealTimeClient.connect() - client.on 'connectionAccepted', fn - client.on 'connectionRejected', fn - return null - - describe 'without cookies', -> - before -> - RealTimeClient.cookie = null - - it 'should return a lookup error', (done) -> - @checkSocket (error) -> - expect(error).to.exist - expect(error.message).to.equal('invalid session') - done() - - describe 'with a different cookie', -> - before -> - RealTimeClient.cookie = "some.key=someValue" - - it 'should return a lookup error', (done) -> - @checkSocket (error) -> - expect(error).to.exist - expect(error.message).to.equal('invalid session') - done() - - describe 'with an invalid cookie', -> - before (done) -> - RealTimeClient.setSession {}, (error) -> - return done(error) if error - RealTimeClient.cookie = "#{Settings.cookieName}=#{ - RealTimeClient.cookie.slice(17, 49) - }" - done() - return null - - it 'should return a lookup error', (done) -> - @checkSocket (error) -> - expect(error).to.exist - expect(error.message).to.equal('invalid session') - done() - - describe 'with a valid cookie and no matching session', -> - before -> - RealTimeClient.cookie = "#{Settings.cookieName}=unknownId" - - it 'should return a lookup error', (done) -> - @checkSocket (error) -> - expect(error).to.exist - expect(error.message).to.equal('invalid session') - done() - - describe 'with a valid cookie and a matching session', -> - before (done) -> - RealTimeClient.setSession({}, done) - return null - - it 'should not return an error', (done) -> - @checkSocket (error) -> - expect(error).to.not.exist - done() diff --git a/services/real-time/test/acceptance/coffee/SessionTests.coffee b/services/real-time/test/acceptance/coffee/SessionTests.coffee deleted file mode 100644 index 23c4e78ce9..0000000000 --- a/services/real-time/test/acceptance/coffee/SessionTests.coffee +++ /dev/null @@ -1,35 +0,0 @@ -chai = require("chai") -expect = chai.expect - -RealTimeClient = require "./helpers/RealTimeClient" - -describe "Session", -> - describe "with an established session", -> - before (done) -> - @user_id = "mock-user-id" - RealTimeClient.setSession { - user: { _id: @user_id } - }, (error) => - throw error if error? - @client = RealTimeClient.connect() - return done() - return null - - it "should not get disconnected", (done) -> - disconnected = false - @client.on "disconnect", () -> - disconnected = true - setTimeout () => - expect(disconnected).to.equal false - done() - , 500 - - it "should appear in the list of connected clients", (done) -> - RealTimeClient.getConnectedClients (error, clients) => - included = false - for client in clients - if client.client_id == @client.socket.sessionid - included = true - break - expect(included).to.equal true - done() diff --git a/services/real-time/test/acceptance/coffee/helpers/FixturesManager.coffee b/services/real-time/test/acceptance/coffee/helpers/FixturesManager.coffee deleted file mode 100644 index 0889b45c2a..0000000000 --- a/services/real-time/test/acceptance/coffee/helpers/FixturesManager.coffee +++ /dev/null @@ -1,48 +0,0 @@ -RealTimeClient = require "./RealTimeClient" -MockWebServer = require "./MockWebServer" -MockDocUpdaterServer = require "./MockDocUpdaterServer" - -module.exports = FixturesManager = - setUpProject: (options = {}, callback = (error, data) ->) -> - options.user_id ||= FixturesManager.getRandomId() - options.project_id ||= FixturesManager.getRandomId() - options.project ||= { name: "Test Project" } - {project_id, user_id, privilegeLevel, project, publicAccess} = options - - privileges = {} - privileges[user_id] = privilegeLevel - if publicAccess - privileges["anonymous-user"] = publicAccess - - MockWebServer.createMockProject(project_id, privileges, project) - MockWebServer.run (error) => - throw error if error? - RealTimeClient.setSession { - user: { - _id: user_id - first_name: "Joe" - last_name: "Bloggs" - } - }, (error) => - throw error if error? - callback null, {project_id, user_id, privilegeLevel, project} - - setUpDoc: (project_id, options = {}, callback = (error, data) ->) -> - options.doc_id ||= FixturesManager.getRandomId() - options.lines ||= ["doc", "lines"] - options.version ||= 42 - options.ops ||= ["mock", "ops"] - {doc_id, lines, version, ops, ranges} = options - - MockDocUpdaterServer.createMockDoc project_id, doc_id, {lines, version, ops, ranges} - MockDocUpdaterServer.run (error) => - throw error if error? - callback null, {project_id, doc_id, lines, version, ops} - - getRandomId: () -> - return require("crypto") - .createHash("sha1") - .update(Math.random().toString()) - .digest("hex") - .slice(0,24) - \ No newline at end of file diff --git a/services/real-time/test/acceptance/coffee/helpers/MockDocUpdaterServer.coffee b/services/real-time/test/acceptance/coffee/helpers/MockDocUpdaterServer.coffee deleted file mode 100644 index ac5bfc7093..0000000000 --- a/services/real-time/test/acceptance/coffee/helpers/MockDocUpdaterServer.coffee +++ /dev/null @@ -1,46 +0,0 @@ -sinon = require "sinon" -express = require "express" - -module.exports = MockDocUpdaterServer = - docs: {} - - createMockDoc: (project_id, doc_id, data) -> - MockDocUpdaterServer.docs["#{project_id}:#{doc_id}"] = data - - getDocument: (project_id, doc_id, fromVersion, callback = (error, data) ->) -> - callback( - null, MockDocUpdaterServer.docs["#{project_id}:#{doc_id}"] - ) - - deleteProject: sinon.stub().callsArg(1) - - getDocumentRequest: (req, res, next) -> - {project_id, doc_id} = req.params - {fromVersion} = req.query - fromVersion = parseInt(fromVersion, 10) - MockDocUpdaterServer.getDocument project_id, doc_id, fromVersion, (error, data) -> - return next(error) if error? - res.json data - - deleteProjectRequest: (req, res, next) -> - {project_id} = req.params - MockDocUpdaterServer.deleteProject project_id, (error) -> - return next(error) if error? - res.sendStatus 204 - - running: false - run: (callback = (error) ->) -> - if MockDocUpdaterServer.running - return callback() - app = express() - app.get "/project/:project_id/doc/:doc_id", MockDocUpdaterServer.getDocumentRequest - app.delete "/project/:project_id", MockDocUpdaterServer.deleteProjectRequest - app.listen 3003, (error) -> - MockDocUpdaterServer.running = true - callback(error) - .on "error", (error) -> - console.error "error starting MockDocUpdaterServer:", error.message - process.exit(1) - - -sinon.spy MockDocUpdaterServer, "getDocument" diff --git a/services/real-time/test/acceptance/coffee/helpers/MockWebServer.coffee b/services/real-time/test/acceptance/coffee/helpers/MockWebServer.coffee deleted file mode 100644 index 7c479c59bb..0000000000 --- a/services/real-time/test/acceptance/coffee/helpers/MockWebServer.coffee +++ /dev/null @@ -1,46 +0,0 @@ -sinon = require "sinon" -express = require "express" - -module.exports = MockWebServer = - projects: {} - privileges: {} - - createMockProject: (project_id, privileges, project) -> - MockWebServer.privileges[project_id] = privileges - MockWebServer.projects[project_id] = project - - joinProject: (project_id, user_id, callback = (error, project, privilegeLevel) ->) -> - callback( - null, - MockWebServer.projects[project_id], - MockWebServer.privileges[project_id][user_id] - ) - - joinProjectRequest: (req, res, next) -> - {project_id} = req.params - {user_id} = req.query - if project_id == 'rate-limited' - res.status(429).send() - else - MockWebServer.joinProject project_id, user_id, (error, project, privilegeLevel) -> - return next(error) if error? - res.json { - project: project - privilegeLevel: privilegeLevel - } - - running: false - run: (callback = (error) ->) -> - if MockWebServer.running - return callback() - app = express() - app.post "/project/:project_id/join", MockWebServer.joinProjectRequest - app.listen 3000, (error) -> - MockWebServer.running = true - callback(error) - .on "error", (error) -> - console.error "error starting MockWebServer:", error.message - process.exit(1) - - -sinon.spy MockWebServer, "joinProject" diff --git a/services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee b/services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee deleted file mode 100644 index 7d54e23b3c..0000000000 --- a/services/real-time/test/acceptance/coffee/helpers/RealTimeClient.coffee +++ /dev/null @@ -1,75 +0,0 @@ -XMLHttpRequest = require("../../libs/XMLHttpRequest").XMLHttpRequest -io = require("socket.io-client") -async = require("async") - -request = require "request" -Settings = require "settings-sharelatex" -redis = require "redis-sharelatex" -rclient = redis.createClient(Settings.redis.websessions) - -uid = require('uid-safe').sync -signature = require("cookie-signature") - -io.util.request = () -> - xhr = new XMLHttpRequest() - _open = xhr.open - xhr.open = () => - _open.apply(xhr, arguments) - if Client.cookie? - xhr.setRequestHeader("Cookie", Client.cookie) - return xhr - -module.exports = Client = - cookie: null - - setSession: (session, callback = (error) ->) -> - sessionId = uid(24) - session.cookie = {} - rclient.set "sess:" + sessionId, JSON.stringify(session), (error) -> - return callback(error) if error? - secret = Settings.security.sessionSecret - cookieKey = 's:' + signature.sign(sessionId, secret) - Client.cookie = "#{Settings.cookieName}=#{cookieKey}" - callback() - - unsetSession: (callback = (error) ->) -> - Client.cookie = null - callback() - - connect: (cookie) -> - client = io.connect("http://localhost:3026", 'force new connection': true) - client.on 'connectionAccepted', (_, publicId) -> - client.publicId = publicId - return client - - getConnectedClients: (callback = (error, clients) ->) -> - request.get { - url: "http://localhost:3026/clients" - json: true - }, (error, response, data) -> - callback error, data - - getConnectedClient: (client_id, callback = (error, clients) ->) -> - request.get { - url: "http://localhost:3026/clients/#{client_id}" - json: true - }, (error, response, data) -> - callback error, data - - - disconnectClient: (client_id, callback) -> - request.post { - url: "http://localhost:3026/client/#{client_id}/disconnect" - auth: { - user: Settings.internal.realTime.user, - pass: Settings.internal.realTime.pass - } - }, (error, response, data) -> - callback error, data - return null - - disconnectAllClients: (callback) -> - Client.getConnectedClients (error, clients) -> - async.each clients, (clientView, cb) -> - Client.disconnectClient clientView.client_id, cb - , callback diff --git a/services/real-time/test/acceptance/coffee/helpers/RealtimeServer.coffee b/services/real-time/test/acceptance/coffee/helpers/RealtimeServer.coffee deleted file mode 100644 index 3a721c18ed..0000000000 --- a/services/real-time/test/acceptance/coffee/helpers/RealtimeServer.coffee +++ /dev/null @@ -1,23 +0,0 @@ -app = require('../../../../app') -logger = require("logger-sharelatex") -Settings = require("settings-sharelatex") - -module.exports = - running: false - initing: false - callbacks: [] - ensureRunning: (callback = (error) ->) -> - if @running - return callback() - else if @initing - @callbacks.push callback - else - @initing = true - @callbacks.push callback - app.listen Settings.internal?.realtime?.port, "localhost", (error) => - throw error if error? - @running = true - logger.log("clsi running in dev mode") - - for callback in @callbacks - callback() diff --git a/services/real-time/test/acceptance/js/ApplyUpdateTests.js b/services/real-time/test/acceptance/js/ApplyUpdateTests.js new file mode 100644 index 0000000000..2c5b753f29 --- /dev/null +++ b/services/real-time/test/acceptance/js/ApplyUpdateTests.js @@ -0,0 +1,447 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * DS201: Simplify complex destructure assignments + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const async = require('async') +const chai = require('chai') +const { expect } = chai +chai.should() + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +const settings = require('settings-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(settings.redis.documentupdater) + +const redisSettings = settings.redis + +describe('applyOtUpdate', function () { + before(function () { + return (this.update = { + op: [{ i: 'foo', p: 42 }] + }) + }) + describe('when authorized', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.update, + cb + ) + } + ], + done + ) + }) + + it('should push the doc into the pending updates list', function (done) { + rclient.lrange('pending-updates-list', 0, -1, (error, ...rest) => { + const [doc_id] = Array.from(rest[0]) + doc_id.should.equal(`${this.project_id}:${this.doc_id}`) + return done() + }) + return null + }) + + it('should push the update into redis', function (done) { + rclient.lrange( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id + }), + 0, + -1, + (error, ...rest) => { + let [update] = Array.from(rest[0]) + update = JSON.parse(update) + update.op.should.deep.equal(this.update.op) + update.meta.should.deep.equal({ + source: this.client.publicId, + user_id: this.user_id + }) + return done() + } + ) + return null + }) + + return after(function (done) { + return async.series( + [ + (cb) => rclient.del('pending-updates-list', cb), + (cb) => + rclient.del( + 'DocsWithPendingUpdates', + `${this.project_id}:${this.doc_id}`, + cb + ), + (cb) => + rclient.del( + redisSettings.documentupdater.key_schema.pendingUpdates( + this.doc_id + ), + cb + ) + ], + done + ) + }) + }) + + describe('when authorized with a huge edit update', function () { + before(function (done) { + this.update = { + op: { + p: 12, + t: 'update is too large'.repeat(1024 * 400) // >7MB + } + } + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + this.client.on('connectionAccepted', cb) + return this.client.on('otUpdateError', (otUpdateError) => { + this.otUpdateError = otUpdateError + }) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.update, + (error) => { + this.error = error + return cb() + } + ) + } + ], + done + ) + }) + + it('should not return an error', function () { + return expect(this.error).to.not.exist + }) + + it('should send an otUpdateError to the client', function (done) { + return setTimeout(() => { + expect(this.otUpdateError).to.exist + return done() + }, 300) + }) + + it('should disconnect the client', function (done) { + return setTimeout(() => { + this.client.socket.connected.should.equal(false) + return done() + }, 300) + }) + + return it('should not put the update in redis', function (done) { + rclient.llen( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id + }), + (error, len) => { + len.should.equal(0) + return done() + } + ) + return null + }) + }) + + describe('when authorized to read-only with an edit update', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readOnly' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.update, + (error) => { + this.error = error + return cb() + } + ) + } + ], + done + ) + }) + + it('should return an error', function () { + return expect(this.error).to.exist + }) + + it('should disconnect the client', function (done) { + return setTimeout(() => { + this.client.socket.connected.should.equal(false) + return done() + }, 300) + }) + + return it('should not put the update in redis', function (done) { + rclient.llen( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id + }), + (error, len) => { + len.should.equal(0) + return done() + } + ) + return null + }) + }) + + return describe('when authorized to read-only with a comment update', function () { + before(function (done) { + this.comment_update = { + op: [{ c: 'foo', p: 42 }] + } + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readOnly' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.comment_update, + cb + ) + } + ], + done + ) + }) + + it('should push the doc into the pending updates list', function (done) { + rclient.lrange('pending-updates-list', 0, -1, (error, ...rest) => { + const [doc_id] = Array.from(rest[0]) + doc_id.should.equal(`${this.project_id}:${this.doc_id}`) + return done() + }) + return null + }) + + it('should push the update into redis', function (done) { + rclient.lrange( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id + }), + 0, + -1, + (error, ...rest) => { + let [update] = Array.from(rest[0]) + update = JSON.parse(update) + update.op.should.deep.equal(this.comment_update.op) + update.meta.should.deep.equal({ + source: this.client.publicId, + user_id: this.user_id + }) + return done() + } + ) + return null + }) + + return after(function (done) { + return async.series( + [ + (cb) => rclient.del('pending-updates-list', cb), + (cb) => + rclient.del( + 'DocsWithPendingUpdates', + `${this.project_id}:${this.doc_id}`, + cb + ), + (cb) => + rclient.del( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id + }), + cb + ) + ], + done + ) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/ClientTrackingTests.js b/services/real-time/test/acceptance/js/ClientTrackingTests.js new file mode 100644 index 0000000000..079baadb58 --- /dev/null +++ b/services/real-time/test/acceptance/js/ClientTrackingTests.js @@ -0,0 +1,255 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-unused-vars, +*/ +// 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const chai = require('chai') +const { expect } = chai +chai.should() + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +describe('clientTracking', function () { + describe('when a client updates its cursor location', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' } + }, + (error, { user_id, project_id }) => { + this.user_id = user_id + this.project_id = project_id + return cb() + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + this.clientB = RealTimeClient.connect() + return this.clientB.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { + project_id: this.project_id + }, + cb + ) + }, + + (cb) => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return this.clientB.emit( + 'joinProject', + { + project_id: this.project_id + }, + cb + ) + }, + + (cb) => { + this.updates = [] + this.clientB.on('clientTracking.clientUpdated', (data) => { + return this.updates.push(data) + }) + + return this.clientA.emit( + 'clientTracking.updatePosition', + { + row: (this.row = 42), + column: (this.column = 36), + doc_id: this.doc_id + }, + (error) => { + if (error != null) { + throw error + } + return setTimeout(cb, 300) + } + ) + } // Give the message a chance to reach client B. + ], + done + ) + }) + + it('should tell other clients about the update', function () { + return this.updates.should.deep.equal([ + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + id: this.clientA.publicId, + user_id: this.user_id, + name: 'Joe Bloggs' + } + ]) + }) + + return it('should record the update in getConnectedUsers', function (done) { + return this.clientB.emit( + 'clientTracking.getConnectedUsers', + (error, users) => { + for (const user of Array.from(users)) { + if (user.client_id === this.clientA.publicId) { + expect(user.cursorData).to.deep.equal({ + row: this.row, + column: this.column, + doc_id: this.doc_id + }) + return done() + } + } + throw new Error('user was never found') + } + ) + }) + }) + + return describe('when an anonymous client updates its cursor location', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' }, + publicAccess: 'readAndWrite' + }, + (error, { user_id, project_id }) => { + this.user_id = user_id + this.project_id = project_id + return cb() + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { + project_id: this.project_id + }, + cb + ) + }, + + (cb) => { + return RealTimeClient.setSession({}, cb) + }, + + (cb) => { + this.anonymous = RealTimeClient.connect() + return this.anonymous.on('connectionAccepted', cb) + }, + + (cb) => { + return this.anonymous.emit( + 'joinProject', + { + project_id: this.project_id + }, + cb + ) + }, + + (cb) => { + return this.anonymous.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + this.updates = [] + this.clientA.on('clientTracking.clientUpdated', (data) => { + return this.updates.push(data) + }) + + return this.anonymous.emit( + 'clientTracking.updatePosition', + { + row: (this.row = 42), + column: (this.column = 36), + doc_id: this.doc_id + }, + (error) => { + if (error != null) { + throw error + } + return setTimeout(cb, 300) + } + ) + } // Give the message a chance to reach client B. + ], + done + ) + }) + + return it('should tell other clients about the update', function () { + return this.updates.should.deep.equal([ + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + id: this.anonymous.publicId, + user_id: 'anonymous-user', + name: '' + } + ]) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/DrainManagerTests.js b/services/real-time/test/acceptance/js/DrainManagerTests.js new file mode 100644 index 0000000000..d312d34aa9 --- /dev/null +++ b/services/real-time/test/acceptance/js/DrainManagerTests.js @@ -0,0 +1,135 @@ +/* eslint-disable + camelcase, +*/ +// 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 RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +const { expect } = require('chai') + +const async = require('async') +const request = require('request') + +const Settings = require('settings-sharelatex') + +const drain = function (rate, callback) { + request.post( + { + url: `http://localhost:3026/drain?rate=${rate}`, + auth: { + user: Settings.internal.realTime.user, + pass: Settings.internal.realTime.pass + } + }, + (error, response, data) => callback(error, data) + ) + return null +} + +describe('DrainManagerTests', function () { + before(function (done) { + FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return done() + } + ) + return null + }) + + before(function (done) { + // cleanup to speedup reconnecting + this.timeout(10000) + return RealTimeClient.disconnectAllClients(done) + }) + + // trigger and check cleanup + it('should have disconnected all previous clients', function (done) { + return RealTimeClient.getConnectedClients((error, data) => { + if (error) { + return done(error) + } + expect(data.length).to.equal(0) + return done() + }) + }) + + return describe('with two clients in the project', function () { + beforeEach(function (done) { + return async.series( + [ + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + this.clientB = RealTimeClient.connect() + return this.clientB.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.clientB.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + } + ], + done + ) + }) + + return describe('starting to drain', function () { + beforeEach(function (done) { + return async.parallel( + [ + (cb) => { + return this.clientA.on('reconnectGracefully', cb) + }, + (cb) => { + return this.clientB.on('reconnectGracefully', cb) + }, + + (cb) => drain(2, cb) + ], + done + ) + }) + + afterEach(function (done) { + return drain(0, done) + }) // reset drain + + it('should not timeout', function () { + return expect(true).to.equal(true) + }) + + return it('should not have disconnected', function () { + expect(this.clientA.socket.connected).to.equal(true) + return expect(this.clientB.socket.connected).to.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/EarlyDisconnect.js b/services/real-time/test/acceptance/js/EarlyDisconnect.js new file mode 100644 index 0000000000..25e8fbc427 --- /dev/null +++ b/services/real-time/test/acceptance/js/EarlyDisconnect.js @@ -0,0 +1,288 @@ +/* eslint-disable + camelcase, + 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 async = require('async') +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const settings = require('settings-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(settings.redis.pubsub) +const rclientRT = redis.createClient(settings.redis.realtime) +const KeysRT = settings.redis.realtime.key_schema + +describe('EarlyDisconnect', function () { + before(function (done) { + return MockDocUpdaterServer.run(done) + }) + + describe('when the client disconnects before joinProject completes', function () { + before(function () { + // slow down web-api requests to force the race condition + let joinProject + this.actualWebAPIjoinProject = joinProject = MockWebServer.joinProject + return (MockWebServer.joinProject = (project_id, user_id, cb) => + setTimeout(() => joinProject(project_id, user_id, cb), 300)) + }) + + after(function () { + return (MockWebServer.joinProject = this.actualWebAPIjoinProject) + }) + + beforeEach(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + () => {} + ) + // disconnect before joinProject completes + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + (cb) => { + // wait for joinDoc and subscribe + return setTimeout(cb, 500) + } + ], + done + ) + }) + + // we can force the race condition, there is no need to repeat too often + return Array.from(Array.from({ length: 5 }).map((_, i) => i + 1)).map( + (attempt) => + it(`should not subscribe to the pub/sub channel anymore (race ${attempt})`, function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + expect(resp).to.not.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + ) + }) + + describe('when the client disconnects before joinDoc completes', function () { + beforeEach(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + // disconnect before joinDoc completes + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + (cb) => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + } + ], + done + ) + }) + + // we can not force the race condition, so we have to try many times + return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map( + (attempt) => + it(`should not subscribe to the pub/sub channels anymore (race ${attempt})`, function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + expect(resp).to.not.include(`editor-events:${this.project_id}`) + + return rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + expect(resp).to.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + }) + return null + }) + ) + }) + + return describe('when the client disconnects before clientTracking.updatePosition starts', function () { + beforeEach(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + this.clientA.emit( + 'clientTracking.updatePosition', + { + row: 42, + column: 36, + doc_id: this.doc_id + }, + () => {} + ) + // disconnect before updateClientPosition completes + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + (cb) => { + // wait for updateClientPosition + return setTimeout(cb, 100) + } + ], + done + ) + }) + + // we can not force the race condition, so we have to try many times + return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map( + (attempt) => + it(`should not show the client as connected (race ${attempt})`, function (done) { + rclientRT.smembers( + KeysRT.clientsInProject({ project_id: this.project_id }), + (err, results) => { + if (err) { + return done(err) + } + expect(results).to.deep.equal([]) + return done() + } + ) + return null + }) + ) + }) +}) diff --git a/services/real-time/test/acceptance/js/HttpControllerTests.js b/services/real-time/test/acceptance/js/HttpControllerTests.js new file mode 100644 index 0000000000..5d40a30def --- /dev/null +++ b/services/real-time/test/acceptance/js/HttpControllerTests.js @@ -0,0 +1,117 @@ +/* eslint-disable + camelcase, +*/ +// 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 async = require('async') +const { expect } = require('chai') +const request = require('request').defaults({ + baseUrl: 'http://localhost: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) { + const client_id = 'not-existing' + return request.get( + { + url: `/clients/${client_id}`, + json: true + }, + (error, response, data) => { + if (error) { + return done(error) + } + expect(response.statusCode).to.equal(404) + return done() + } + ) + }) + }) + + return describe('with a user and after joining a project', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner' + }, + (error, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + {}, + (error, { doc_id }) => { + this.doc_id = doc_id + return cb(error) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit('joinDoc', this.doc_id, cb) + } + ], + done + ) + }) + + return it('should send a client view', function (done) { + return request.get( + { + url: `/clients/${this.client.socket.sessionid}`, + json: true + }, + (error, response, data) => { + if (error) { + return done(error) + } + expect(response.statusCode).to.equal(200) + expect(data.connected_time).to.exist + delete data.connected_time + // .email is not set in the session + delete data.email + expect(data).to.deep.equal({ + client_id: this.client.socket.sessionid, + first_name: 'Joe', + last_name: 'Bloggs', + project_id: this.project_id, + user_id: this.user_id, + rooms: [this.project_id, this.doc_id] + }) + return done() + } + ) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/JoinDocTests.js b/services/real-time/test/acceptance/js/JoinDocTests.js new file mode 100644 index 0000000000..217731a1df --- /dev/null +++ b/services/real-time/test/acceptance/js/JoinDocTests.js @@ -0,0 +1,565 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 chai = require('chai') +const { expect } = chai +chai.should() + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +describe('joinDoc', function () { + before(function () { + this.lines = ['test', 'doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'doc', 'ops'] + return (this.ranges = { mock: 'ranges' }) + }) + + describe('when authorised readAndWrite', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + describe('when authorised readOnly', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readOnly' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + describe('when authorised as owner', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + // It is impossible to write an acceptance test to test joining an unauthorized + // project, since joinProject already catches that. If you can join a project, + // then you can join a doc in that project. + + describe('with a fromVersion', function () { + before(function (done) { + this.fromVersion = 36 + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + this.fromVersion, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the doc from the doc updater with the fromVersion', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, this.fromVersion) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + describe('with options', function () { + before(function (done) { + this.options = { encodeRanges: true } + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + this.options, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the doc from the doc updater with the default fromVersion', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + return describe('with fromVersion and options', function () { + before(function (done) { + this.fromVersion = 36 + this.options = { encodeRanges: true } + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + this.fromVersion, + this.options, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the doc from the doc updater with the fromVersion', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, this.fromVersion) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/JoinProjectTests.js b/services/real-time/test/acceptance/js/JoinProjectTests.js new file mode 100644 index 0000000000..051c33d0c7 --- /dev/null +++ b/services/real-time/test/acceptance/js/JoinProjectTests.js @@ -0,0 +1,208 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// 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 chai = require('chai') +const { expect } = chai +chai.should() + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +describe('joinProject', function () { + describe('when authorized', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + } + ], + done + ) + }) + + it('should get the project from web', function () { + return MockWebServer.joinProject + .calledWith(this.project_id, this.user_id) + .should.equal(true) + }) + + it('should return the project', function () { + return this.project.should.deep.equal({ + name: 'Test Project' + }) + }) + + it('should return the privilege level', function () { + return this.privilegeLevel.should.equal('owner') + }) + + it('should return the protocolVersion', function () { + return this.protocolVersion.should.equal(2) + }) + + it('should have joined the project room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.project_id)).to.equal( + true + ) + return done() + } + ) + }) + + return it('should have marked the user as connected', function (done) { + return this.client.emit( + 'clientTracking.getConnectedUsers', + (error, users) => { + let connected = false + for (const user of Array.from(users)) { + if ( + user.client_id === this.client.publicId && + user.user_id === this.user_id + ) { + connected = true + break + } + } + expect(connected).to.equal(true) + return done() + } + ) + }) + }) + + describe('when not authorized', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: null, + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.error = error + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb() + } + ) + } + ], + done + ) + }) + + it('should return an error', function () { + return this.error.message.should.equal('not authorized') + }) + + return it('should not have joined the project room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.project_id)).to.equal( + false + ) + return done() + } + ) + }) + }) + + return describe('when over rate limit', function () { + before(function (done) { + return async.series( + [ + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: 'rate-limited' }, + (error) => { + this.error = error + return cb() + } + ) + } + ], + done + ) + }) + + return it('should return a TooManyRequests error code', function () { + this.error.message.should.equal('rate-limit hit when joining project') + return this.error.code.should.equal('TooManyRequests') + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/LeaveDocTests.js b/services/real-time/test/acceptance/js/LeaveDocTests.js new file mode 100644 index 0000000000..a842087522 --- /dev/null +++ b/services/real-time/test/acceptance/js/LeaveDocTests.js @@ -0,0 +1,176 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, + no-unused-vars, +*/ +// 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const chai = require('chai') +const { expect } = chai +chai.should() +const sinon = require('sinon') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') +const logger = require('logger-sharelatex') + +const async = require('async') + +describe('leaveDoc', function () { + before(function () { + this.lines = ['test', 'doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'doc', 'ops'] + sinon.spy(logger, 'error') + sinon.spy(logger, 'warn') + sinon.spy(logger, 'log') + return (this.other_doc_id = FixturesManager.getRandomId()) + }) + + after(function () { + logger.error.restore() // remove the spy + logger.warn.restore() + return logger.log.restore() + }) + + return describe('when joined to a doc', function () { + beforeEach(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite' + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + (cb) => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + } + ], + done + ) + }) + + describe('then leaving the doc', function () { + beforeEach(function (done) { + return this.client.emit('leaveDoc', this.doc_id, (error) => { + if (error != null) { + throw error + } + return done() + }) + }) + + return it('should have left the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal( + false + ) + return done() + } + ) + }) + }) + + describe('when sending a leaveDoc request before the previous joinDoc request has completed', function () { + beforeEach(function (done) { + this.client.emit('leaveDoc', this.doc_id, () => {}) + this.client.emit('joinDoc', this.doc_id, () => {}) + return this.client.emit('leaveDoc', this.doc_id, (error) => { + if (error != null) { + throw error + } + return done() + }) + }) + + it('should not trigger an error', function () { + return sinon.assert.neverCalledWith( + logger.error, + sinon.match.any, + "not subscribed - shouldn't happen" + ) + }) + + return it('should have left the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal( + false + ) + return done() + } + ) + }) + }) + + return describe('when sending a leaveDoc for a room the client has not joined ', function () { + beforeEach(function (done) { + return this.client.emit('leaveDoc', this.other_doc_id, (error) => { + if (error != null) { + throw error + } + return done() + }) + }) + + return it('should trigger a low level message only', function () { + return sinon.assert.calledWith( + logger.log, + sinon.match.any, + 'ignoring request from client to leave room it is not in' + ) + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/LeaveProjectTests.js b/services/real-time/test/acceptance/js/LeaveProjectTests.js new file mode 100644 index 0000000000..61976d481f --- /dev/null +++ b/services/real-time/test/acceptance/js/LeaveProjectTests.js @@ -0,0 +1,270 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-throw-literal, +*/ +// 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 RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +const settings = require('settings-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(settings.redis.pubsub) + +describe('leaveProject', function () { + before(function (done) { + return MockDocUpdaterServer.run(done) + }) + + describe('with other clients in the project', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + this.clientB = RealTimeClient.connect() + this.clientB.on('connectionAccepted', cb) + + this.clientBDisconnectMessages = [] + return this.clientB.on( + 'clientTracking.clientDisconnected', + (data) => { + return this.clientBDisconnectMessages.push(data) + } + ) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return this.clientB.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + (cb) => { + return this.clientB.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + // leaveProject is called when the client disconnects + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + (cb) => { + // The API waits a little while before flushing changes + return setTimeout(done, 1000) + } + ], + done + ) + }) + + it('should emit a disconnect message to the room', function () { + return this.clientBDisconnectMessages.should.deep.equal([ + this.clientA.publicId + ]) + }) + + it('should no longer list the client in connected users', function (done) { + return this.clientB.emit( + 'clientTracking.getConnectedUsers', + (error, users) => { + for (const user of Array.from(users)) { + if (user.client_id === this.clientA.publicId) { + throw 'Expected clientA to not be listed in connected users' + } + } + return done() + } + ) + }) + + it('should not flush the project to the document updater', function () { + return MockDocUpdaterServer.deleteProject + .calledWith(this.project_id) + .should.equal(false) + }) + + it('should remain subscribed to the editor-events channels', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + + return it('should remain subscribed to the applied-ops channels', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + return describe('with no other clients in the project', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + (cb) => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + // leaveProject is called when the client disconnects + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + (cb) => { + // The API waits a little while before flushing changes + return setTimeout(done, 1000) + } + ], + done + ) + }) + + it('should flush the project to the document updater', function () { + return MockDocUpdaterServer.deleteProject + .calledWith(this.project_id) + .should.equal(true) + }) + + it('should not subscribe to the editor-events channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/PubSubRace.js b/services/real-time/test/acceptance/js/PubSubRace.js new file mode 100644 index 0000000000..a824ef3e82 --- /dev/null +++ b/services/real-time/test/acceptance/js/PubSubRace.js @@ -0,0 +1,373 @@ +/* eslint-disable + camelcase, + 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 RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +const settings = require('settings-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(settings.redis.pubsub) + +describe('PubSubRace', function () { + before(function (done) { + return MockDocUpdaterServer.run(done) + }) + + describe('when the client leaves a doc before joinDoc completes', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + // leave before joinDoc completes + return this.clientA.emit('leaveDoc', this.doc_id, cb) + }, + + (cb) => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + } + ], + done + ) + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + describe('when the client emits joinDoc and leaveDoc requests frequently and leaves eventually', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + return this.clientA.emit('leaveDoc', this.doc_id, cb) + }, + + (cb) => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + } + ], + done + ) + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + describe('when the client emits joinDoc and leaveDoc requests frequently and remains in the doc', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + } + ], + done + ) + }) + + return it('should subscribe to the applied-ops channels', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + return describe('when the client disconnects before joinDoc completes', function () { + before(function (done) { + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + let joinDocCompleted = false + this.clientA.emit( + 'joinDoc', + this.doc_id, + () => (joinDocCompleted = true) + ) + // leave before joinDoc completes + return setTimeout( + () => { + if (joinDocCompleted) { + return cb(new Error('joinDocCompleted -- lower timeout')) + } + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + // socket.io processes joinDoc and disconnect with different delays: + // - joinDoc goes through two process.nextTick + // - disconnect goes through one process.nextTick + // We have to inject the disconnect event into a different event loop + // cycle. + 3 + ) + }, + + (cb) => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + } + ], + done + ) + }) + + it('should not subscribe to the editor-events channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/ReceiveUpdateTests.js b/services/real-time/test/acceptance/js/ReceiveUpdateTests.js new file mode 100644 index 0000000000..9c65be19f9 --- /dev/null +++ b/services/real-time/test/acceptance/js/ReceiveUpdateTests.js @@ -0,0 +1,349 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const chai = require('chai') +const { expect } = chai +chai.should() + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +const settings = require('settings-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(settings.redis.pubsub) + +describe('receiveUpdate', function () { + beforeEach(function (done) { + this.lines = ['test', 'doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'doc', 'ops'] + + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' } + }, + (error, { user_id, project_id }) => { + this.user_id = user_id + this.project_id = project_id + return cb() + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + (cb) => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + (cb) => { + this.clientB = RealTimeClient.connect() + return this.clientB.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientA.emit( + 'joinProject', + { + project_id: this.project_id + }, + cb + ) + }, + + (cb) => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return this.clientB.emit( + 'joinProject', + { + project_id: this.project_id + }, + cb + ) + }, + + (cb) => { + return this.clientB.emit('joinDoc', this.doc_id, cb) + }, + + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' } + }, + ( + error, + { user_id: user_id_second, project_id: project_id_second } + ) => { + this.user_id_second = user_id_second + this.project_id_second = project_id_second + return cb() + } + ) + }, + + (cb) => { + return FixturesManager.setUpDoc( + this.project_id_second, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id: doc_id_second }) => { + this.doc_id_second = doc_id_second + return cb(e) + } + ) + }, + + (cb) => { + this.clientC = RealTimeClient.connect() + return this.clientC.on('connectionAccepted', cb) + }, + + (cb) => { + return this.clientC.emit( + 'joinProject', + { + project_id: this.project_id_second + }, + cb + ) + }, + (cb) => { + return this.clientC.emit('joinDoc', this.doc_id_second, cb) + }, + + (cb) => { + this.clientAUpdates = [] + this.clientA.on('otUpdateApplied', (update) => + this.clientAUpdates.push(update) + ) + this.clientBUpdates = [] + this.clientB.on('otUpdateApplied', (update) => + this.clientBUpdates.push(update) + ) + this.clientCUpdates = [] + this.clientC.on('otUpdateApplied', (update) => + this.clientCUpdates.push(update) + ) + + this.clientAErrors = [] + this.clientA.on('otUpdateError', (error) => + this.clientAErrors.push(error) + ) + this.clientBErrors = [] + this.clientB.on('otUpdateError', (error) => + this.clientBErrors.push(error) + ) + this.clientCErrors = [] + this.clientC.on('otUpdateError', (error) => + this.clientCErrors.push(error) + ) + return cb() + } + ], + done + ) + }) + + afterEach(function () { + if (this.clientA != null) { + this.clientA.disconnect() + } + if (this.clientB != null) { + this.clientB.disconnect() + } + return this.clientC != null ? this.clientC.disconnect() : undefined + }) + + describe('with an update from clientA', function () { + beforeEach(function (done) { + this.update = { + doc_id: this.doc_id, + op: { + meta: { + source: this.clientA.publicId + }, + v: this.version, + doc: this.doc_id, + op: [{ i: 'foo', p: 50 }] + } + } + rclient.publish('applied-ops', JSON.stringify(this.update)) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send the full op to clientB', function () { + return this.clientBUpdates.should.deep.equal([this.update.op]) + }) + + it('should send an ack to clientA', function () { + return this.clientAUpdates.should.deep.equal([ + { + v: this.version, + doc: this.doc_id + } + ]) + }) + + return it('should send nothing to clientC', function () { + return this.clientCUpdates.should.deep.equal([]) + }) + }) + + describe('with an update from clientC', function () { + beforeEach(function (done) { + this.update = { + doc_id: this.doc_id_second, + op: { + meta: { + source: this.clientC.publicId + }, + v: this.version, + doc: this.doc_id_second, + op: [{ i: 'update from clientC', p: 50 }] + } + } + rclient.publish('applied-ops', JSON.stringify(this.update)) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send nothing to clientA', function () { + return this.clientAUpdates.should.deep.equal([]) + }) + + it('should send nothing to clientB', function () { + return this.clientBUpdates.should.deep.equal([]) + }) + + return it('should send an ack to clientC', function () { + return this.clientCUpdates.should.deep.equal([ + { + v: this.version, + doc: this.doc_id_second + } + ]) + }) + }) + + describe('with an update from a remote client for project 1', function () { + beforeEach(function (done) { + this.update = { + doc_id: this.doc_id, + op: { + meta: { + source: 'this-is-a-remote-client-id' + }, + v: this.version, + doc: this.doc_id, + op: [{ i: 'foo', p: 50 }] + } + } + rclient.publish('applied-ops', JSON.stringify(this.update)) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send the full op to clientA', function () { + return this.clientAUpdates.should.deep.equal([this.update.op]) + }) + + it('should send the full op to clientB', function () { + return this.clientBUpdates.should.deep.equal([this.update.op]) + }) + + return it('should send nothing to clientC', function () { + return this.clientCUpdates.should.deep.equal([]) + }) + }) + + describe('with an error for the first project', function () { + beforeEach(function (done) { + rclient.publish( + 'applied-ops', + JSON.stringify({ + doc_id: this.doc_id, + error: (this.error = 'something went wrong') + }) + ) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send the error to the clients in the first project', function () { + this.clientAErrors.should.deep.equal([this.error]) + return this.clientBErrors.should.deep.equal([this.error]) + }) + + it('should not send any errors to the client in the second project', function () { + return this.clientCErrors.should.deep.equal([]) + }) + + it('should disconnect the clients of the first project', function () { + this.clientA.socket.connected.should.equal(false) + return this.clientB.socket.connected.should.equal(false) + }) + + return it('should not disconnect the client in the second project', function () { + return this.clientC.socket.connected.should.equal(true) + }) + }) + + return describe('with an error for the second project', function () { + beforeEach(function (done) { + rclient.publish( + 'applied-ops', + JSON.stringify({ + doc_id: this.doc_id_second, + error: (this.error = 'something went wrong') + }) + ) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should not send any errors to the clients in the first project', function () { + this.clientAErrors.should.deep.equal([]) + return this.clientBErrors.should.deep.equal([]) + }) + + it('should send the error to the client in the second project', function () { + return this.clientCErrors.should.deep.equal([this.error]) + }) + + it('should not disconnect the clients of the first project', function () { + this.clientA.socket.connected.should.equal(true) + return this.clientB.socket.connected.should.equal(true) + }) + + return it('should disconnect the client in the second project', function () { + return this.clientC.socket.connected.should.equal(false) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/RouterTests.js b/services/real-time/test/acceptance/js/RouterTests.js new file mode 100644 index 0000000000..729947281c --- /dev/null +++ b/services/real-time/test/acceptance/js/RouterTests.js @@ -0,0 +1,121 @@ +/* eslint-disable + camelcase, +*/ +// 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 async = require('async') +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +describe('Router', function () { + return describe('joinProject', function () { + describe('when there is no callback provided', function () { + after(function () { + return process.removeListener('unhandledRejection', this.onUnhandled) + }) + + before(function (done) { + this.onUnhandled = (error) => done(error) + process.on('unhandledRejection', this.onUnhandled) + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + this.client.emit('joinProject', { project_id: this.project_id }) + return setTimeout(cb, 100) + } + ], + done + ) + }) + + return it('should keep on going', function () { + return expect('still running').to.exist + }) + }) + + return describe('when there are too many arguments', function () { + after(function () { + return process.removeListener('unhandledRejection', this.onUnhandled) + }) + + before(function (done) { + this.onUnhandled = (error) => done(error) + process.on('unhandledRejection', this.onUnhandled) + return async.series( + [ + (cb) => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project' + } + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + (cb) => { + return this.client.emit('joinProject', 1, 2, 3, 4, 5, (error) => { + this.error = error + return cb() + }) + } + ], + done + ) + }) + + return it('should return an error message', function () { + return expect(this.error.message).to.equal('unexpected arguments') + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/SessionSocketsTests.js b/services/real-time/test/acceptance/js/SessionSocketsTests.js new file mode 100644 index 0000000000..45f62195e5 --- /dev/null +++ b/services/real-time/test/acceptance/js/SessionSocketsTests.js @@ -0,0 +1,103 @@ +/* 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 RealTimeClient = require('./helpers/RealTimeClient') +const Settings = require('settings-sharelatex') +const { expect } = require('chai') + +describe('SessionSockets', function () { + before(function () { + return (this.checkSocket = function (fn) { + const client = RealTimeClient.connect() + client.on('connectionAccepted', fn) + client.on('connectionRejected', fn) + return null + }) + }) + + describe('without cookies', function () { + before(function () { + return (RealTimeClient.cookie = null) + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket((error) => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + describe('with a different cookie', function () { + before(function () { + return (RealTimeClient.cookie = 'some.key=someValue') + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket((error) => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + describe('with an invalid cookie', function () { + before(function (done) { + RealTimeClient.setSession({}, (error) => { + if (error) { + return done(error) + } + RealTimeClient.cookie = `${ + Settings.cookieName + }=${RealTimeClient.cookie.slice(17, 49)}` + return done() + }) + return null + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket((error) => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + describe('with a valid cookie and no matching session', function () { + before(function () { + return (RealTimeClient.cookie = `${Settings.cookieName}=unknownId`) + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket((error) => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + return describe('with a valid cookie and a matching session', function () { + before(function (done) { + RealTimeClient.setSession({}, done) + return null + }) + + return it('should not return an error', function (done) { + return this.checkSocket((error) => { + expect(error).to.not.exist + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/SessionTests.js b/services/real-time/test/acceptance/js/SessionTests.js new file mode 100644 index 0000000000..941a59f4b9 --- /dev/null +++ b/services/real-time/test/acceptance/js/SessionTests.js @@ -0,0 +1,61 @@ +/* eslint-disable + handle-callback-err, + 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const chai = require('chai') +const { expect } = chai + +const RealTimeClient = require('./helpers/RealTimeClient') + +describe('Session', function () { + return describe('with an established session', function () { + before(function (done) { + this.user_id = 'mock-user-id' + RealTimeClient.setSession( + { + user: { _id: this.user_id } + }, + (error) => { + if (error != null) { + throw error + } + this.client = RealTimeClient.connect() + return done() + } + ) + return null + }) + + it('should not get disconnected', function (done) { + let disconnected = false + this.client.on('disconnect', () => (disconnected = true)) + return setTimeout(() => { + expect(disconnected).to.equal(false) + return done() + }, 500) + }) + + return it('should appear in the list of connected clients', function (done) { + return RealTimeClient.getConnectedClients((error, clients) => { + let included = false + for (const client of Array.from(clients)) { + if (client.client_id === this.client.socket.sessionid) { + included = true + break + } + } + expect(included).to.equal(true) + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/helpers/FixturesManager.js b/services/real-time/test/acceptance/js/helpers/FixturesManager.js new file mode 100644 index 0000000000..3e72961cbf --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/FixturesManager.js @@ -0,0 +1,119 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// 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 + * 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') + +module.exports = FixturesManager = { + setUpProject(options, callback) { + if (options == null) { + options = {} + } + if (callback == null) { + callback = function (error, data) {} + } + if (!options.user_id) { + options.user_id = FixturesManager.getRandomId() + } + if (!options.project_id) { + options.project_id = FixturesManager.getRandomId() + } + if (!options.project) { + options.project = { name: 'Test Project' } + } + const { + project_id, + user_id, + privilegeLevel, + project, + publicAccess + } = options + + const privileges = {} + privileges[user_id] = privilegeLevel + if (publicAccess) { + privileges['anonymous-user'] = publicAccess + } + + MockWebServer.createMockProject(project_id, privileges, project) + return MockWebServer.run((error) => { + if (error != null) { + throw error + } + return RealTimeClient.setSession( + { + user: { + _id: user_id, + first_name: 'Joe', + last_name: 'Bloggs' + } + }, + (error) => { + if (error != null) { + throw error + } + return callback(null, { + project_id, + user_id, + privilegeLevel, + project + }) + } + ) + }) + }, + + setUpDoc(project_id, options, callback) { + if (options == null) { + options = {} + } + if (callback == null) { + callback = function (error, data) {} + } + if (!options.doc_id) { + options.doc_id = FixturesManager.getRandomId() + } + if (!options.lines) { + options.lines = ['doc', 'lines'] + } + if (!options.version) { + options.version = 42 + } + if (!options.ops) { + options.ops = ['mock', 'ops'] + } + const { doc_id, lines, version, ops, ranges } = options + + MockDocUpdaterServer.createMockDoc(project_id, doc_id, { + lines, + version, + ops, + ranges + }) + return MockDocUpdaterServer.run((error) => { + if (error != null) { + throw error + } + return callback(null, { project_id, doc_id, lines, version, ops }) + }) + }, + + getRandomId() { + return require('crypto') + .createHash('sha1') + .update(Math.random().toString()) + .digest('hex') + .slice(0, 24) + } +} diff --git a/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js b/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js new file mode 100644 index 0000000000..f9dcc57bf7 --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js @@ -0,0 +1,90 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * 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') + +module.exports = MockDocUpdaterServer = { + docs: {}, + + createMockDoc(project_id, doc_id, data) { + return (MockDocUpdaterServer.docs[`${project_id}:${doc_id}`] = data) + }, + + getDocument(project_id, doc_id, fromVersion, callback) { + if (callback == null) { + callback = function (error, data) {} + } + return callback(null, MockDocUpdaterServer.docs[`${project_id}:${doc_id}`]) + }, + + deleteProject: sinon.stub().callsArg(1), + + getDocumentRequest(req, res, next) { + const { project_id, doc_id } = req.params + let { fromVersion } = req.query + fromVersion = parseInt(fromVersion, 10) + return MockDocUpdaterServer.getDocument( + project_id, + doc_id, + fromVersion, + (error, data) => { + if (error != null) { + return next(error) + } + return res.json(data) + } + ) + }, + + deleteProjectRequest(req, res, next) { + const { project_id } = req.params + return MockDocUpdaterServer.deleteProject(project_id, (error) => { + if (error != null) { + return next(error) + } + return res.sendStatus(204) + }) + }, + + running: false, + run(callback) { + if (callback == null) { + callback = function (error) {} + } + if (MockDocUpdaterServer.running) { + return callback() + } + const app = express() + app.get( + '/project/:project_id/doc/:doc_id', + MockDocUpdaterServer.getDocumentRequest + ) + app.delete( + '/project/:project_id', + MockDocUpdaterServer.deleteProjectRequest + ) + return app + .listen(3003, (error) => { + MockDocUpdaterServer.running = true + return callback(error) + }) + .on('error', (error) => { + console.error('error starting MockDocUpdaterServer:', error.message) + return process.exit(1) + }) + } +} + +sinon.spy(MockDocUpdaterServer, 'getDocument') diff --git a/services/real-time/test/acceptance/js/helpers/MockWebServer.js b/services/real-time/test/acceptance/js/helpers/MockWebServer.js new file mode 100644 index 0000000000..a2cf5af50b --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/MockWebServer.js @@ -0,0 +1,82 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * 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') + +module.exports = MockWebServer = { + projects: {}, + privileges: {}, + + createMockProject(project_id, privileges, project) { + MockWebServer.privileges[project_id] = privileges + return (MockWebServer.projects[project_id] = project) + }, + + joinProject(project_id, user_id, callback) { + if (callback == null) { + callback = function (error, project, privilegeLevel) {} + } + return callback( + null, + MockWebServer.projects[project_id], + MockWebServer.privileges[project_id][user_id] + ) + }, + + joinProjectRequest(req, res, next) { + const { project_id } = req.params + const { user_id } = req.query + if (project_id === 'rate-limited') { + return res.status(429).send() + } else { + return MockWebServer.joinProject( + project_id, + user_id, + (error, project, privilegeLevel) => { + if (error != null) { + return next(error) + } + return res.json({ + project, + privilegeLevel + }) + } + ) + } + }, + + running: false, + run(callback) { + if (callback == null) { + callback = function (error) {} + } + if (MockWebServer.running) { + return callback() + } + const app = express() + app.post('/project/:project_id/join', MockWebServer.joinProjectRequest) + return app + .listen(3000, (error) => { + MockWebServer.running = true + return callback(error) + }) + .on('error', (error) => { + console.error('error starting MockWebServer:', error.message) + return process.exit(1) + }) + } +} + +sinon.spy(MockWebServer, 'joinProject') diff --git a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js new file mode 100644 index 0000000000..ab8e222d93 --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js @@ -0,0 +1,131 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Client +const { XMLHttpRequest } = require('../../libs/XMLHttpRequest') +const io = require('socket.io-client') +const async = require('async') + +const request = require('request') +const Settings = require('settings-sharelatex') +const redis = require('redis-sharelatex') +const rclient = redis.createClient(Settings.redis.websessions) + +const uid = require('uid-safe').sync +const signature = require('cookie-signature') + +io.util.request = function () { + const xhr = new XMLHttpRequest() + const _open = xhr.open + xhr.open = function () { + _open.apply(xhr, arguments) + if (Client.cookie != null) { + return xhr.setRequestHeader('Cookie', Client.cookie) + } + } + return xhr +} + +module.exports = Client = { + cookie: null, + + setSession(session, callback) { + if (callback == null) { + callback = function (error) {} + } + const sessionId = uid(24) + session.cookie = {} + return rclient.set( + 'sess:' + sessionId, + JSON.stringify(session), + (error) => { + if (error != null) { + return callback(error) + } + const secret = Settings.security.sessionSecret + const cookieKey = 's:' + signature.sign(sessionId, secret) + Client.cookie = `${Settings.cookieName}=${cookieKey}` + return callback() + } + ) + }, + + unsetSession(callback) { + if (callback == null) { + callback = function (error) {} + } + Client.cookie = null + return callback() + }, + + connect(cookie) { + const client = io.connect('http://localhost:3026', { + 'force new connection': true + }) + client.on( + 'connectionAccepted', + (_, publicId) => (client.publicId = publicId) + ) + return client + }, + + getConnectedClients(callback) { + if (callback == null) { + callback = function (error, clients) {} + } + return request.get( + { + url: 'http://localhost:3026/clients', + json: true + }, + (error, response, data) => callback(error, data) + ) + }, + + getConnectedClient(client_id, callback) { + if (callback == null) { + callback = function (error, clients) {} + } + return request.get( + { + url: `http://localhost:3026/clients/${client_id}`, + json: true + }, + (error, response, data) => callback(error, data) + ) + }, + + disconnectClient(client_id, callback) { + request.post( + { + url: `http://localhost:3026/client/${client_id}/disconnect`, + auth: { + user: Settings.internal.realTime.user, + pass: Settings.internal.realTime.pass + } + }, + (error, response, data) => callback(error, data) + ) + return null + }, + + disconnectAllClients(callback) { + return Client.getConnectedClients((error, clients) => + async.each( + clients, + (clientView, cb) => Client.disconnectClient(clientView.client_id, cb), + callback + ) + ) + } +} diff --git a/services/real-time/test/acceptance/js/helpers/RealtimeServer.js b/services/real-time/test/acceptance/js/helpers/RealtimeServer.js new file mode 100644 index 0000000000..950c4b966d --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/RealtimeServer.js @@ -0,0 +1,64 @@ +/* eslint-disable + handle-callback-err, +*/ +// 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('logger-sharelatex') +const Settings = require('settings-sharelatex') + +module.exports = { + running: false, + initing: false, + callbacks: [], + ensureRunning(callback) { + if (callback == null) { + callback = function (error) {} + } + 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 + ), + 'localhost', + (error) => { + if (error != null) { + throw error + } + this.running = true + logger.log('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 +} diff --git a/services/real-time/test/acceptance/libs/XMLHttpRequest.js b/services/real-time/test/acceptance/libs/XMLHttpRequest.js index e79634da5e..0222bc906b 100644 --- a/services/real-time/test/acceptance/libs/XMLHttpRequest.js +++ b/services/real-time/test/acceptance/libs/XMLHttpRequest.js @@ -11,100 +11,95 @@ * @license MIT */ -var Url = require("url") - , spawn = require("child_process").spawn - , fs = require('fs'); +const { URL } = require('url') +var spawn = require('child_process').spawn +var fs = require('fs') -exports.XMLHttpRequest = function() { +exports.XMLHttpRequest = function () { /** * Private variables */ - var self = this; - var http = require('http'); - var https = require('https'); + var self = this + var http = require('http') + var https = require('https') // Holds http.js objects - var client; - var request; - var response; + var request + var response // Request settings - var settings = {}; + var settings = {} // Set some default headers var defaultHeaders = { - "User-Agent": "node-XMLHttpRequest", - "Accept": "*/*", - }; + 'User-Agent': 'node-XMLHttpRequest', + Accept: '*/*' + } - var headers = defaultHeaders; + var headers = defaultHeaders // These headers are not user setable. // The following are allowed but banned in the spec: // * user-agent var forbiddenRequestHeaders = [ - "accept-charset", - "accept-encoding", - "access-control-request-headers", - "access-control-request-method", - "connection", - "content-length", - "content-transfer-encoding", - //"cookie", - "cookie2", - "date", - "expect", - "host", - "keep-alive", - "origin", - "referer", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "via" - ]; + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'content-transfer-encoding', + // "cookie", + 'cookie2', + 'date', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via' + ] // These request methods are not allowed - var forbiddenRequestMethods = [ - "TRACE", - "TRACK", - "CONNECT" - ]; + var forbiddenRequestMethods = ['TRACE', 'TRACK', 'CONNECT'] // Send flag - var sendFlag = false; + var sendFlag = false // Error flag, used when errors occur or abort is called - var errorFlag = false; + var errorFlag = false // Event listeners - var listeners = {}; + var listeners = {} /** * Constants */ - this.UNSENT = 0; - this.OPENED = 1; - this.HEADERS_RECEIVED = 2; - this.LOADING = 3; - this.DONE = 4; + this.UNSENT = 0 + this.OPENED = 1 + this.HEADERS_RECEIVED = 2 + this.LOADING = 3 + this.DONE = 4 /** * Public vars */ // Current state - this.readyState = this.UNSENT; + this.readyState = this.UNSENT // default ready state change handler in case one is not set or is set late - this.onreadystatechange = null; + this.onreadystatechange = null // Result & response - this.responseText = ""; - this.responseXML = ""; - this.status = null; - this.statusText = null; + this.responseText = '' + this.responseXML = '' + this.status = null + this.statusText = null /** * Private methods @@ -116,9 +111,11 @@ exports.XMLHttpRequest = function() { * @param string header Header to validate * @return boolean False if not allowed, otherwise true */ - var isAllowedHttpHeader = function(header) { - return (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); - }; + var isAllowedHttpHeader = function (header) { + return ( + header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1 + ) + } /** * Check if the specified method is allowed. @@ -126,9 +123,9 @@ exports.XMLHttpRequest = function() { * @param string method Request method to validate * @return boolean False if not allowed, otherwise true */ - var isAllowedHttpMethod = function(method) { - return (method && forbiddenRequestMethods.indexOf(method) === -1); - }; + var isAllowedHttpMethod = function (method) { + return method && forbiddenRequestMethods.indexOf(method) === -1 + } /** * Public methods @@ -143,26 +140,25 @@ exports.XMLHttpRequest = function() { * @param string user Username for basic authentication (optional) * @param string password Password for basic authentication (optional) */ - this.open = function(method, url, async, user, password) { - this.abort(); - errorFlag = false; + this.open = function (method, url, async, user, password) { + this.abort() + errorFlag = false // Check for valid request method if (!isAllowedHttpMethod(method)) { - throw "SecurityError: Request method not allowed"; - return; + throw new Error('SecurityError: Request method not allowed') } settings = { - "method": method, - "url": url.toString(), - "async": (typeof async !== "boolean" ? true : async), - "user": user || null, - "password": password || null - }; + method: method, + url: url.toString(), + async: typeof async !== 'boolean' ? true : async, + user: user || null, + password: password || null + } - setState(this.OPENED); - }; + setState(this.OPENED) + } /** * Sets a header for the request. @@ -170,19 +166,21 @@ exports.XMLHttpRequest = function() { * @param string header Header name * @param string value Header value */ - this.setRequestHeader = function(header, value) { - if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"; + this.setRequestHeader = function (header, value) { + if (this.readyState !== this.OPENED) { + throw new Error( + 'INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN' + ) } if (!isAllowedHttpHeader(header)) { - console.warn('Refused to set unsafe header "' + header + '"'); - return; + console.warn('Refused to set unsafe header "' + header + '"') + return } if (sendFlag) { - throw "INVALID_STATE_ERR: send flag is true"; + throw new Error('INVALID_STATE_ERR: send flag is true') } - headers[header] = value; - }; + headers[header] = value + } /** * Gets a header from the server response. @@ -190,37 +188,38 @@ exports.XMLHttpRequest = function() { * @param string header Name of header to get. * @return string Text of the header or null if it doesn't exist. */ - this.getResponseHeader = function(header) { - if (typeof header === "string" - && this.readyState > this.OPENED - && response.headers[header.toLowerCase()] - && !errorFlag + this.getResponseHeader = function (header) { + if ( + typeof header === 'string' && + this.readyState > this.OPENED && + response.headers[header.toLowerCase()] && + !errorFlag ) { - return response.headers[header.toLowerCase()]; + return response.headers[header.toLowerCase()] } - return null; - }; + return null + } /** * Gets all the response headers. * * @return string A string with all response headers separated by CR+LF */ - this.getAllResponseHeaders = function() { + this.getAllResponseHeaders = function () { if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { - return ""; + return '' } - var result = ""; + var result = '' for (var i in response.headers) { // Cookie headers are excluded - if (i !== "set-cookie" && i !== "set-cookie2") { - result += i + ": " + response.headers[i] + "\r\n"; + if (i !== 'set-cookie' && i !== 'set-cookie2') { + result += i + ': ' + response.headers[i] + '\r\n' } } - return result.substr(0, result.length - 2); - }; + return result.substr(0, result.length - 2) + } /** * Gets a request header @@ -228,13 +227,13 @@ exports.XMLHttpRequest = function() { * @param string name Name of header to get * @return string Returns the request header or empty string if not set */ - this.getRequestHeader = function(name) { + this.getRequestHeader = function (name) { // @TODO Make this case insensitive - if (typeof name === "string" && headers[name]) { - return headers[name]; + if (typeof name === 'string' && headers[name]) { + return headers[name] } - return ""; + return '' } /** @@ -242,103 +241,108 @@ exports.XMLHttpRequest = function() { * * @param string data Optional data to send as request body. */ - this.send = function(data) { - if (this.readyState != this.OPENED) { - throw "INVALID_STATE_ERR: connection must be opened before send() is called"; + this.send = function (data) { + if (this.readyState !== this.OPENED) { + throw new Error( + 'INVALID_STATE_ERR: connection must be opened before send() is called' + ) } if (sendFlag) { - throw "INVALID_STATE_ERR: send has already been called"; + throw new Error('INVALID_STATE_ERR: send has already been called') } - var ssl = false, local = false; - var url = Url.parse(settings.url); + var host + var ssl = false + var local = false + var url = new URL(settings.url) // Determine the server switch (url.protocol) { case 'https:': - ssl = true; - // SSL & non-SSL both need host, no break here. + ssl = true + host = url.hostname + break case 'http:': - var host = url.hostname; - break; + host = url.hostname + break case 'file:': - local = true; - break; + local = true + break case undefined: case '': - var host = "localhost"; - break; + host = 'localhost' + break default: - throw "Protocol not supported."; + throw new Error('Protocol not supported.') } // Load files off the local filesystem (file://) if (local) { - if (settings.method !== "GET") { - throw "XMLHttpRequest: Only GET method is supported"; + if (settings.method !== 'GET') { + throw new Error('XMLHttpRequest: Only GET method is supported') } if (settings.async) { - fs.readFile(url.pathname, 'utf8', function(error, data) { + fs.readFile(url.pathname, 'utf8', (error, data) => { if (error) { - self.handleError(error); + self.handleError(error) } else { - self.status = 200; - self.responseText = data; - setState(self.DONE); + self.status = 200 + self.responseText = data + setState(self.DONE) } - }); + }) } else { try { - this.responseText = fs.readFileSync(url.pathname, 'utf8'); - this.status = 200; - setState(self.DONE); - } catch(e) { - this.handleError(e); + this.responseText = fs.readFileSync(url.pathname, 'utf8') + this.status = 200 + setState(self.DONE) + } catch (e) { + this.handleError(e) } } - return; + return } // Default to port 80. If accessing localhost on another port be sure // to use http://localhost:port/path - var port = url.port || (ssl ? 443 : 80); + var port = url.port || (ssl ? 443 : 80) // Add query string if one is used - var uri = url.pathname + (url.search ? url.search : ''); + var uri = url.pathname + (url.search ? url.search : '') // Set the Host header or the server may reject the request - headers["Host"] = host; + headers.Host = host if (!((ssl && port === 443) || port === 80)) { - headers["Host"] += ':' + url.port; + headers.Host += ':' + url.port } // Set Basic Auth if necessary if (settings.user) { - if (typeof settings.password == "undefined") { - settings.password = ""; + if (typeof settings.password === 'undefined') { + settings.password = '' } - var authBuf = new Buffer(settings.user + ":" + settings.password); - headers["Authorization"] = "Basic " + authBuf.toString("base64"); + var authBuf = Buffer.from(settings.user + ':' + settings.password) + headers.Authorization = 'Basic ' + authBuf.toString('base64') } // Set content length header - if (settings.method === "GET" || settings.method === "HEAD") { - data = null; + if (settings.method === 'GET' || settings.method === 'HEAD') { + data = null } else if (data) { - headers["Content-Length"] = Buffer.byteLength(data); + headers['Content-Length'] = Buffer.byteLength(data) - if (!headers["Content-Type"]) { - headers["Content-Type"] = "text/plain;charset=UTF-8"; + if (!headers['Content-Type']) { + headers['Content-Type'] = 'text/plain;charset=UTF-8' } - } else if (settings.method === "POST") { + } else if (settings.method === 'POST') { // For a post with no data set Content-Length: 0. // This is required by buggy servers that don't meet the specs. - headers["Content-Length"] = 0; + headers['Content-Length'] = 0 } var options = { @@ -347,202 +351,229 @@ exports.XMLHttpRequest = function() { path: uri, method: settings.method, headers: headers - }; + } // Reset error flag - errorFlag = false; + errorFlag = false // Handle async requests if (settings.async) { // Use the proper protocol - var doRequest = ssl ? https.request : http.request; + var doRequest = ssl ? https.request : http.request // Request is being sent, set send flag - sendFlag = true; + sendFlag = true // As per spec, this is called here for historical reasons. - self.dispatchEvent("readystatechange"); + self.dispatchEvent('readystatechange') // Create the request - request = doRequest(options, function(resp) { - response = resp; - response.setEncoding("utf8"); + request = doRequest(options, (resp) => { + response = resp + response.setEncoding('utf8') - setState(self.HEADERS_RECEIVED); - self.status = response.statusCode; + setState(self.HEADERS_RECEIVED) + self.status = response.statusCode - response.on('data', function(chunk) { + response.on('data', (chunk) => { // Make sure there's some data if (chunk) { - self.responseText += chunk; + self.responseText += chunk } // Don't emit state changes if the connection has been aborted. if (sendFlag) { - setState(self.LOADING); + setState(self.LOADING) } - }); + }) - response.on('end', function() { + response.on('end', () => { if (sendFlag) { // Discard the 'end' event if the connection has been aborted - setState(self.DONE); - sendFlag = false; + setState(self.DONE) + sendFlag = false } - }); + }) - response.on('error', function(error) { - self.handleError(error); - }); - }).on('error', function(error) { - self.handleError(error); - }); + response.on('error', (error) => { + self.handleError(error) + }) + }).on('error', (error) => { + self.handleError(error) + }) // Node 0.4 and later won't accept empty data. Make sure it's needed. if (data) { - request.write(data); + request.write(data) } - request.end(); + request.end() - self.dispatchEvent("loadstart"); - } else { // Synchronous + self.dispatchEvent('loadstart') + } else { + // Synchronous // Create a temporary file for communication with the other Node process - var syncFile = ".node-xmlhttprequest-sync-" + process.pid; - fs.writeFileSync(syncFile, "", "utf8"); + var syncFile = '.node-xmlhttprequest-sync-' + process.pid + fs.writeFileSync(syncFile, '', 'utf8') // The async request the other Node process executes - var execString = "var http = require('http'), https = require('https'), fs = require('fs');" - + "var doRequest = http" + (ssl ? "s" : "") + ".request;" - + "var options = " + JSON.stringify(options) + ";" - + "var responseText = '';" - + "var req = doRequest(options, function(response) {" - + "response.setEncoding('utf8');" - + "response.on('data', function(chunk) {" - + "responseText += chunk;" - + "});" - + "response.on('end', function() {" - + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "});" - + "}).on('error', function(error) {" - + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "});" - + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"") - + "req.end();"; + var execString = + "var http = require('http'), https = require('https'), fs = require('fs');" + + 'var doRequest = http' + + (ssl ? 's' : '') + + '.request;' + + 'var options = ' + + JSON.stringify(options) + + ';' + + "var responseText = '';" + + 'var req = doRequest(options, function(response) {' + + "response.setEncoding('utf8');" + + "response.on('data', function(chunk) {" + + 'responseText += chunk;' + + '});' + + "response.on('end', function() {" + + "fs.writeFileSync('" + + syncFile + + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" + + '});' + + "response.on('error', function(error) {" + + "fs.writeFileSync('" + + syncFile + + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + '});' + + "}).on('error', function(error) {" + + "fs.writeFileSync('" + + syncFile + + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + '});' + + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');" : '') + + 'req.end();' // Start the other Node Process, executing this string - syncProc = spawn(process.argv[0], ["-e", execString]); - while((self.responseText = fs.readFileSync(syncFile, 'utf8')) == "") { + const syncProc = spawn(process.argv[0], ['-e', execString]) + while ((self.responseText = fs.readFileSync(syncFile, 'utf8')) === '') { // Wait while the file is empty } // Kill the child process once the file has data - syncProc.stdin.end(); + syncProc.stdin.end() // Remove the temporary file - fs.unlinkSync(syncFile); + fs.unlinkSync(syncFile) if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { // If the file returned an error, handle it - var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, ""); - self.handleError(errorObj); + var errorObj = self.responseText.replace( + /^NODE-XMLHTTPREQUEST-ERROR:/, + '' + ) + self.handleError(errorObj) } else { // If the file returned okay, parse its data and move to the DONE state - self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1"); - self.responseText = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1"); - setState(self.DONE); + self.status = self.responseText.replace( + /^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, + '$1' + ) + self.responseText = self.responseText.replace( + /^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, + '$1' + ) + setState(self.DONE) } } - }; + } /** * Called when an error is encountered to deal with it. */ - this.handleError = function(error) { - this.status = 503; - this.statusText = error; - this.responseText = error.stack; - errorFlag = true; - setState(this.DONE); - }; + this.handleError = function (error) { + this.status = 503 + this.statusText = error + this.responseText = error.stack + errorFlag = true + setState(this.DONE) + } /** * Aborts a request. */ - this.abort = function() { + this.abort = function () { if (request) { - request.abort(); - request = null; + request.abort() + request = null } - headers = defaultHeaders; - this.responseText = ""; - this.responseXML = ""; + headers = defaultHeaders + this.responseText = '' + this.responseXML = '' - errorFlag = true; + errorFlag = true - if (this.readyState !== this.UNSENT - && (this.readyState !== this.OPENED || sendFlag) - && this.readyState !== this.DONE) { - sendFlag = false; - setState(this.DONE); + if ( + this.readyState !== this.UNSENT && + (this.readyState !== this.OPENED || sendFlag) && + this.readyState !== this.DONE + ) { + sendFlag = false + setState(this.DONE) } - this.readyState = this.UNSENT; - }; + this.readyState = this.UNSENT + } /** * Adds an event listener. Preferred method of binding to events. */ - this.addEventListener = function(event, callback) { + this.addEventListener = function (event, callback) { if (!(event in listeners)) { - listeners[event] = []; + listeners[event] = [] } // Currently allows duplicate callbacks. Should it? - listeners[event].push(callback); - }; + listeners[event].push(callback) + } /** * Remove an event callback that has already been bound. * Only works on the matching funciton, cannot be a copy. */ - this.removeEventListener = function(event, callback) { + this.removeEventListener = function (event, callback) { if (event in listeners) { // Filter will return a new array with the callback removed - listeners[event] = listeners[event].filter(function(ev) { - return ev !== callback; - }); + listeners[event] = listeners[event].filter((ev) => { + return ev !== callback + }) } - }; + } /** * Dispatch any events, including both "on" methods and events attached using addEventListener. */ - this.dispatchEvent = function(event) { - if (typeof self["on" + event] === "function") { - self["on" + event](); + this.dispatchEvent = function (event) { + if (typeof self['on' + event] === 'function') { + self['on' + event]() } if (event in listeners) { for (var i = 0, len = listeners[event].length; i < len; i++) { - listeners[event][i].call(self); + listeners[event][i].call(self) } } - }; + } /** * Changes readyState and calls onreadystatechange. * * @param int state New state */ - var setState = function(state) { + var setState = function (state) { if (self.readyState !== state) { - self.readyState = state; + self.readyState = state - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); + if ( + settings.async || + self.readyState < self.OPENED || + self.readyState === self.DONE + ) { + self.dispatchEvent('readystatechange') } if (self.readyState === self.DONE && !errorFlag) { - self.dispatchEvent("load"); + self.dispatchEvent('load') // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); + self.dispatchEvent('loadend') } } - }; -}; + } +} diff --git a/services/real-time/test/unit/coffee/AuthorizationManagerTests.coffee b/services/real-time/test/unit/coffee/AuthorizationManagerTests.coffee deleted file mode 100644 index 143218d8b2..0000000000 --- a/services/real-time/test/unit/coffee/AuthorizationManagerTests.coffee +++ /dev/null @@ -1,166 +0,0 @@ -chai = require "chai" -chai.should() -expect = chai.expect -sinon = require("sinon") -SandboxedModule = require('sandboxed-module') -path = require "path" -modulePath = '../../../app/js/AuthorizationManager' - -describe 'AuthorizationManager', -> - beforeEach -> - @client = - ol_context: {} - - @AuthorizationManager = SandboxedModule.require modulePath, requires: {} - - describe "assertClientCanViewProject", -> - it "should allow the readOnly privilegeLevel", (done) -> - @client.ol_context.privilege_level = "readOnly" - @AuthorizationManager.assertClientCanViewProject @client, (error) -> - expect(error).to.be.null - done() - - it "should allow the readAndWrite privilegeLevel", (done) -> - @client.ol_context.privilege_level = "readAndWrite" - @AuthorizationManager.assertClientCanViewProject @client, (error) -> - expect(error).to.be.null - done() - - it "should allow the owner privilegeLevel", (done) -> - @client.ol_context.privilege_level = "owner" - @AuthorizationManager.assertClientCanViewProject @client, (error) -> - expect(error).to.be.null - done() - - it "should return an error with any other privilegeLevel", (done) -> - @client.ol_context.privilege_level = "unknown" - @AuthorizationManager.assertClientCanViewProject @client, (error) -> - error.message.should.equal "not authorized" - done() - - describe "assertClientCanEditProject", -> - it "should not allow the readOnly privilegeLevel", (done) -> - @client.ol_context.privilege_level = "readOnly" - @AuthorizationManager.assertClientCanEditProject @client, (error) -> - error.message.should.equal "not authorized" - done() - - it "should allow the readAndWrite privilegeLevel", (done) -> - @client.ol_context.privilege_level = "readAndWrite" - @AuthorizationManager.assertClientCanEditProject @client, (error) -> - expect(error).to.be.null - done() - - it "should allow the owner privilegeLevel", (done) -> - @client.ol_context.privilege_level = "owner" - @AuthorizationManager.assertClientCanEditProject @client, (error) -> - expect(error).to.be.null - done() - - it "should return an error with any other privilegeLevel", (done) -> - @client.ol_context.privilege_level = "unknown" - @AuthorizationManager.assertClientCanEditProject @client, (error) -> - error.message.should.equal "not authorized" - done() - - # check doc access for project - - describe "assertClientCanViewProjectAndDoc", -> - beforeEach () -> - @doc_id = "12345" - @callback = sinon.stub() - @client.ol_context = {} - - describe "when not authorised at the project level", -> - beforeEach () -> - @client.ol_context.privilege_level = "unknown" - - it "should not allow access", () -> - @AuthorizationManager.assertClientCanViewProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "even when authorised at the doc level", -> - beforeEach (done) -> - @AuthorizationManager.addAccessToDoc @client, @doc_id, done - - it "should not allow access", () -> - @AuthorizationManager.assertClientCanViewProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "when authorised at the project level", -> - beforeEach () -> - @client.ol_context.privilege_level = "readOnly" - - describe "and not authorised at the document level", -> - it "should not allow access", () -> - @AuthorizationManager.assertClientCanViewProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "and authorised at the document level", -> - beforeEach (done) -> - @AuthorizationManager.addAccessToDoc @client, @doc_id, done - - it "should allow access", () -> - @AuthorizationManager.assertClientCanViewProjectAndDoc @client, @doc_id, @callback - @callback - .calledWith(null) - .should.equal true - - describe "when document authorisation is added and then removed", -> - beforeEach (done) -> - @AuthorizationManager.addAccessToDoc @client, @doc_id, () => - @AuthorizationManager.removeAccessToDoc @client, @doc_id, done - - it "should deny access", () -> - @AuthorizationManager.assertClientCanViewProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "assertClientCanEditProjectAndDoc", -> - beforeEach () -> - @doc_id = "12345" - @callback = sinon.stub() - @client.ol_context = {} - - describe "when not authorised at the project level", -> - beforeEach () -> - @client.ol_context.privilege_level = "readOnly" - - it "should not allow access", () -> - @AuthorizationManager.assertClientCanEditProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "even when authorised at the doc level", -> - beforeEach (done) -> - @AuthorizationManager.addAccessToDoc @client, @doc_id, done - - it "should not allow access", () -> - @AuthorizationManager.assertClientCanEditProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "when authorised at the project level", -> - beforeEach () -> - @client.ol_context.privilege_level = "readAndWrite" - - describe "and not authorised at the document level", -> - it "should not allow access", () -> - @AuthorizationManager.assertClientCanEditProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" - - describe "and authorised at the document level", -> - beforeEach (done) -> - @AuthorizationManager.addAccessToDoc @client, @doc_id, done - - it "should allow access", () -> - @AuthorizationManager.assertClientCanEditProjectAndDoc @client, @doc_id, @callback - @callback - .calledWith(null) - .should.equal true - - describe "when document authorisation is added and then removed", -> - beforeEach (done) -> - @AuthorizationManager.addAccessToDoc @client, @doc_id, () => - @AuthorizationManager.removeAccessToDoc @client, @doc_id, done - - it "should deny access", () -> - @AuthorizationManager.assertClientCanEditProjectAndDoc @client, @doc_id, (err) -> - err.message.should.equal "not authorized" diff --git a/services/real-time/test/unit/coffee/ChannelManagerTests.coffee b/services/real-time/test/unit/coffee/ChannelManagerTests.coffee deleted file mode 100644 index 354e956283..0000000000 --- a/services/real-time/test/unit/coffee/ChannelManagerTests.coffee +++ /dev/null @@ -1,220 +0,0 @@ -chai = require('chai') -should = chai.should() -expect = chai.expect -sinon = require("sinon") -modulePath = "../../../app/js/ChannelManager.js" -SandboxedModule = require('sandboxed-module') - -describe 'ChannelManager', -> - beforeEach -> - @rclient = {} - @other_rclient = {} - @ChannelManager = SandboxedModule.require modulePath, requires: - "settings-sharelatex": @settings = {} - "metrics-sharelatex": @metrics = {inc: sinon.stub(), summary: sinon.stub()} - "logger-sharelatex": @logger = { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } - - describe "subscribe", -> - - describe "when there is no existing subscription for this redis client", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub().resolves() - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - - it "should subscribe to the redis channel", -> - @rclient.subscribe.calledWithExactly("applied-ops:1234567890abcdef").should.equal true - - describe "when there is an existing subscription for this redis client", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub().resolves() - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - - it "should subscribe to the redis channel again", -> - @rclient.subscribe.callCount.should.equal 2 - - describe "when subscribe errors", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub() - .onFirstCall().rejects(new Error("some redis error")) - .onSecondCall().resolves() - p = @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - p.then () -> - done(new Error('should not subscribe but fail')) - .catch (err) => - err.message.should.equal "some redis error" - @ChannelManager.getClientMapEntry(@rclient).has("applied-ops:1234567890abcdef").should.equal false - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - # subscribe is wrapped in Promise, delay other assertions - setTimeout done - return null - - it "should have recorded the error", -> - expect(@metrics.inc.calledWithExactly("subscribe.failed.applied-ops")).to.equal(true) - - it "should subscribe again", -> - @rclient.subscribe.callCount.should.equal 2 - - it "should cleanup", -> - @ChannelManager.getClientMapEntry(@rclient).has("applied-ops:1234567890abcdef").should.equal false - - describe "when subscribe errors and the clientChannelMap entry was replaced", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub() - .onFirstCall().rejects(new Error("some redis error")) - .onSecondCall().resolves() - @first = @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - # ignore error - @first.catch((()->)) - expect(@ChannelManager.getClientMapEntry(@rclient).get("applied-ops:1234567890abcdef")).to.equal @first - - @rclient.unsubscribe = sinon.stub().resolves() - @ChannelManager.unsubscribe @rclient, "applied-ops", "1234567890abcdef" - @second = @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - # should get replaced immediately - expect(@ChannelManager.getClientMapEntry(@rclient).get("applied-ops:1234567890abcdef")).to.equal @second - - # let the first subscribe error -> unsubscribe -> subscribe - setTimeout done - - it "should cleanup the second subscribePromise", -> - expect(@ChannelManager.getClientMapEntry(@rclient).has("applied-ops:1234567890abcdef")).to.equal false - - describe "when there is an existing subscription for another redis client but not this one", -> - beforeEach (done) -> - @other_rclient.subscribe = sinon.stub().resolves() - @ChannelManager.subscribe @other_rclient, "applied-ops", "1234567890abcdef" - @rclient.subscribe = sinon.stub().resolves() # discard the original stub - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - - it "should subscribe to the redis channel on this redis client", -> - @rclient.subscribe.calledWithExactly("applied-ops:1234567890abcdef").should.equal true - - describe "unsubscribe", -> - - describe "when there is no existing subscription for this redis client", -> - beforeEach (done) -> - @rclient.unsubscribe = sinon.stub().resolves() - @ChannelManager.unsubscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - - it "should unsubscribe from the redis channel", -> - @rclient.unsubscribe.called.should.equal true - - - describe "when there is an existing subscription for this another redis client but not this one", -> - beforeEach (done) -> - @other_rclient.subscribe = sinon.stub().resolves() - @rclient.unsubscribe = sinon.stub().resolves() - @ChannelManager.subscribe @other_rclient, "applied-ops", "1234567890abcdef" - @ChannelManager.unsubscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - - it "should still unsubscribe from the redis channel on this client", -> - @rclient.unsubscribe.called.should.equal true - - describe "when unsubscribe errors and completes", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub().resolves() - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - @rclient.unsubscribe = sinon.stub().rejects(new Error("some redis error")) - @ChannelManager.unsubscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - return null - - it "should have cleaned up", -> - @ChannelManager.getClientMapEntry(@rclient).has("applied-ops:1234567890abcdef").should.equal false - - it "should not error out when subscribing again", (done) -> - p = @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - p.then () -> - done() - .catch done - return null - - describe "when unsubscribe errors and another client subscribes at the same time", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub().resolves() - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - rejectSubscribe = undefined - @rclient.unsubscribe = () -> - return new Promise (resolve, reject) -> - rejectSubscribe = reject - @ChannelManager.unsubscribe @rclient, "applied-ops", "1234567890abcdef" - - setTimeout () => - # delay, actualUnsubscribe should not see the new subscribe request - @ChannelManager.subscribe(@rclient, "applied-ops", "1234567890abcdef") - .then () -> - setTimeout done - .catch done - setTimeout -> - # delay, rejectSubscribe is not defined immediately - rejectSubscribe(new Error("redis error")) - return null - - it "should have recorded the error", -> - expect(@metrics.inc.calledWithExactly("unsubscribe.failed.applied-ops")).to.equal(true) - - it "should have subscribed", -> - @rclient.subscribe.called.should.equal true - - it "should have discarded the finished Promise", -> - @ChannelManager.getClientMapEntry(@rclient).has("applied-ops:1234567890abcdef").should.equal false - - describe "when there is an existing subscription for this redis client", -> - beforeEach (done) -> - @rclient.subscribe = sinon.stub().resolves() - @rclient.unsubscribe = sinon.stub().resolves() - @ChannelManager.subscribe @rclient, "applied-ops", "1234567890abcdef" - @ChannelManager.unsubscribe @rclient, "applied-ops", "1234567890abcdef" - setTimeout done - - it "should unsubscribe from the redis channel", -> - @rclient.unsubscribe.calledWithExactly("applied-ops:1234567890abcdef").should.equal true - - describe "publish", -> - - describe "when the channel is 'all'", -> - beforeEach -> - @rclient.publish = sinon.stub() - @ChannelManager.publish @rclient, "applied-ops", "all", "random-message" - - it "should publish on the base channel", -> - @rclient.publish.calledWithExactly("applied-ops", "random-message").should.equal true - - describe "when the channel has an specific id", -> - - describe "when the individual channel setting is false", -> - beforeEach -> - @rclient.publish = sinon.stub() - @settings.publishOnIndividualChannels = false - @ChannelManager.publish @rclient, "applied-ops", "1234567890abcdef", "random-message" - - it "should publish on the per-id channel", -> - @rclient.publish.calledWithExactly("applied-ops", "random-message").should.equal true - @rclient.publish.calledOnce.should.equal true - - describe "when the individual channel setting is true", -> - beforeEach -> - @rclient.publish = sinon.stub() - @settings.publishOnIndividualChannels = true - @ChannelManager.publish @rclient, "applied-ops", "1234567890abcdef", "random-message" - - it "should publish on the per-id channel", -> - @rclient.publish.calledWithExactly("applied-ops:1234567890abcdef", "random-message").should.equal true - @rclient.publish.calledOnce.should.equal true - - describe "metrics", -> - beforeEach -> - @rclient.publish = sinon.stub() - @ChannelManager.publish @rclient, "applied-ops", "all", "random-message" - - it "should track the payload size", -> - @metrics.summary.calledWithExactly( - "redis.publish.applied-ops", - "random-message".length - ).should.equal true diff --git a/services/real-time/test/unit/coffee/ConnectedUsersManagerTests.coffee b/services/real-time/test/unit/coffee/ConnectedUsersManagerTests.coffee deleted file mode 100644 index 6fb3942b64..0000000000 --- a/services/real-time/test/unit/coffee/ConnectedUsersManagerTests.coffee +++ /dev/null @@ -1,164 +0,0 @@ - -should = require('chai').should() -SandboxedModule = require('sandboxed-module') -assert = require('assert') -path = require('path') -sinon = require('sinon') -modulePath = path.join __dirname, "../../../app/js/ConnectedUsersManager" -expect = require("chai").expect -tk = require("timekeeper") - - -describe "ConnectedUsersManager", -> - - beforeEach -> - - @settings = - redis: - realtime: - key_schema: - clientsInProject: ({project_id}) -> "clients_in_project:#{project_id}" - connectedUser: ({project_id, client_id})-> "connected_user:#{project_id}:#{client_id}" - @rClient = - auth:-> - 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 @rClient - tk.freeze(new Date()) - - @ConnectedUsersManager = SandboxedModule.require modulePath, requires: - "settings-sharelatex":@settings - "logger-sharelatex": log:-> - "redis-sharelatex": createClient:=> - return @rClient - @client_id = "32132132" - @project_id = "dskjh2u21321" - @user = { - _id: "user-id-123" - first_name: "Joe" - last_name: "Bloggs" - email: "joe@example.com" - } - @cursorData = { row: 12, column: 9, doc_id: '53c3b8c85fee64000023dc6e' } - - afterEach -> - tk.reset() - - describe "updateUserPosition", -> - beforeEach -> - @rClient.exec.callsArgWith(0) - - it "should set a key with the date and give it a ttl", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "last_updated_at", Date.now()).should.equal true - done() - - it "should set a key with the user_id", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "user_id", @user._id).should.equal true - done() - - it "should set a key with the first_name", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "first_name", @user.first_name).should.equal true - done() - - it "should set a key with the last_name", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "last_name", @user.last_name).should.equal true - done() - - it "should set a key with the email", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "email", @user.email).should.equal true - done() - - it "should push the client_id on to the project list", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.sadd.calledWith("clients_in_project:#{@project_id}", @client_id).should.equal true - done() - - it "should add a ttl to the project set so it stays clean", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.expire.calledWith("clients_in_project:#{@project_id}", 24 * 4 * 60 * 60).should.equal true - done() - - it "should add a ttl to the connected user so it stays clean", (done) -> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, null, (err)=> - @rClient.expire.calledWith("connected_user:#{@project_id}:#{@client_id}", 60 * 15).should.equal true - done() - - it "should set the cursor position when provided", (done)-> - @ConnectedUsersManager.updateUserPosition @project_id, @client_id, @user, @cursorData, (err)=> - @rClient.hset.calledWith("connected_user:#{@project_id}:#{@client_id}", "cursorData", JSON.stringify(@cursorData)).should.equal true - done() - - describe "markUserAsDisconnected", -> - beforeEach -> - @rClient.exec.callsArgWith(0) - - it "should remove the user from the set", (done)-> - @ConnectedUsersManager.markUserAsDisconnected @project_id, @client_id, (err)=> - @rClient.srem.calledWith("clients_in_project:#{@project_id}", @client_id).should.equal true - done() - - it "should delete the connected_user string", (done)-> - @ConnectedUsersManager.markUserAsDisconnected @project_id, @client_id, (err)=> - @rClient.del.calledWith("connected_user:#{@project_id}:#{@client_id}").should.equal true - done() - - it "should add a ttl to the connected user set so it stays clean", (done)-> - @ConnectedUsersManager.markUserAsDisconnected @project_id, @client_id, (err)=> - @rClient.expire.calledWith("clients_in_project:#{@project_id}", 24 * 4 * 60 * 60).should.equal true - done() - - describe "_getConnectedUser", -> - - it "should return a connected user if there is a user object", (done)-> - cursorData = JSON.stringify(cursorData:{row:1}) - @rClient.hgetall.callsArgWith(1, null, {connected_at:new Date(), user_id: @user._id, last_updated_at: "#{Date.now()}", cursorData}) - @ConnectedUsersManager._getConnectedUser @project_id, @client_id, (err, result)=> - result.connected.should.equal true - result.client_id.should.equal @client_id - done() - - it "should return a not connected user if there is no object", (done)-> - @rClient.hgetall.callsArgWith(1, null, null) - @ConnectedUsersManager._getConnectedUser @project_id, @client_id, (err, result)=> - result.connected.should.equal false - result.client_id.should.equal @client_id - done() - - it "should return a not connected user if there is an empty object", (done)-> - @rClient.hgetall.callsArgWith(1, null, {}) - @ConnectedUsersManager._getConnectedUser @project_id, @client_id, (err, result)=> - result.connected.should.equal false - result.client_id.should.equal @client_id - done() - - describe "getConnectedUsers", -> - - beforeEach -> - @users = ["1234", "5678", "9123", "8234"] - @rClient.smembers.callsArgWith(1, null, @users) - @ConnectedUsersManager._getConnectedUser = sinon.stub() - @ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[0]).callsArgWith(2, null, {connected:true, client_age: 2, client_id:@users[0]}) - @ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[1]).callsArgWith(2, null, {connected:false, client_age: 1, client_id:@users[1]}) - @ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[2]).callsArgWith(2, null, {connected:true, client_age: 3, client_id:@users[2]}) - @ConnectedUsersManager._getConnectedUser.withArgs(@project_id, @users[3]).callsArgWith(2, null, {connected:true, client_age: 11, client_id:@users[3]}) # connected but old - - it "should only return the users in the list which are still in redis and recently updated", (done)-> - @ConnectedUsersManager.getConnectedUsers @project_id, (err, users)=> - users.length.should.equal 2 - users[0].should.deep.equal {client_id:@users[0], client_age: 2, connected:true} - users[1].should.deep.equal {client_id:@users[2], client_age: 3, connected:true} - done() - diff --git a/services/real-time/test/unit/coffee/DocumentUpdaterControllerTests.coffee b/services/real-time/test/unit/coffee/DocumentUpdaterControllerTests.coffee deleted file mode 100644 index b2e52c7d56..0000000000 --- a/services/real-time/test/unit/coffee/DocumentUpdaterControllerTests.coffee +++ /dev/null @@ -1,153 +0,0 @@ -SandboxedModule = require('sandboxed-module') -sinon = require('sinon') -require('chai').should() -modulePath = require('path').join __dirname, '../../../app/js/DocumentUpdaterController' -MockClient = require "./helpers/MockClient" - -describe "DocumentUpdaterController", -> - beforeEach -> - @project_id = "project-id-123" - @doc_id = "doc-id-123" - @callback = sinon.stub() - @io = { "mock": "socket.io" } - @rclient = [] - @RoomEvents = { on: sinon.stub() } - @EditorUpdatesController = SandboxedModule.require modulePath, requires: - "logger-sharelatex": @logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() } - "settings-sharelatex": @settings = - redis: - documentupdater: - key_schema: - pendingUpdates: ({doc_id}) -> "PendingUpdates:#{doc_id}" - pubsub: null - "redis-sharelatex" : @redis = - createClient: (name) => - @rclient.push(rclientStub = {name:name}) - return rclientStub - "./SafeJsonParse": @SafeJsonParse = - parse: (data, cb) => cb null, JSON.parse(data) - "./EventLogger": @EventLogger = {checkEventOrder: sinon.stub()} - "./HealthCheckManager": {check: sinon.stub()} - "metrics-sharelatex": @metrics = {inc: sinon.stub()} - "./RoomManager" : @RoomManager = { eventSource: sinon.stub().returns @RoomEvents} - "./ChannelManager": @ChannelManager = {} - - describe "listenForUpdatesFromDocumentUpdater", -> - beforeEach -> - @rclient.length = 0 # clear any existing clients - @EditorUpdatesController.rclientList = [@redis.createClient("first"), @redis.createClient("second")] - @rclient[0].subscribe = sinon.stub() - @rclient[0].on = sinon.stub() - @rclient[1].subscribe = sinon.stub() - @rclient[1].on = sinon.stub() - @EditorUpdatesController.listenForUpdatesFromDocumentUpdater() - - it "should subscribe to the doc-updater stream", -> - @rclient[0].subscribe.calledWith("applied-ops").should.equal true - - it "should register a callback to handle updates", -> - @rclient[0].on.calledWith("message").should.equal true - - it "should subscribe to any additional doc-updater stream", -> - @rclient[1].subscribe.calledWith("applied-ops").should.equal true - @rclient[1].on.calledWith("message").should.equal true - - describe "_processMessageFromDocumentUpdater", -> - describe "with bad JSON", -> - beforeEach -> - @SafeJsonParse.parse = sinon.stub().callsArgWith 1, new Error("oops") - @EditorUpdatesController._processMessageFromDocumentUpdater @io, "applied-ops", "blah" - - it "should log an error", -> - @logger.error.called.should.equal true - - describe "with update", -> - beforeEach -> - @message = - doc_id: @doc_id - op: {t: "foo", p: 12} - @EditorUpdatesController._applyUpdateFromDocumentUpdater = sinon.stub() - @EditorUpdatesController._processMessageFromDocumentUpdater @io, "applied-ops", JSON.stringify(@message) - - it "should apply the update", -> - @EditorUpdatesController._applyUpdateFromDocumentUpdater - .calledWith(@io, @doc_id, @message.op) - .should.equal true - - describe "with error", -> - beforeEach -> - @message = - doc_id: @doc_id - error: "Something went wrong" - @EditorUpdatesController._processErrorFromDocumentUpdater = sinon.stub() - @EditorUpdatesController._processMessageFromDocumentUpdater @io, "applied-ops", JSON.stringify(@message) - - it "should process the error", -> - @EditorUpdatesController._processErrorFromDocumentUpdater - .calledWith(@io, @doc_id, @message.error) - .should.equal true - - describe "_applyUpdateFromDocumentUpdater", -> - beforeEach -> - @sourceClient = new MockClient() - @otherClients = [new MockClient(), new MockClient()] - @update = - op: [ t: "foo", p: 12 ] - meta: source: @sourceClient.publicId - v: @version = 42 - doc: @doc_id - @io.sockets = - clients: sinon.stub().returns([@sourceClient, @otherClients..., @sourceClient]) # include a duplicate client - - describe "normally", -> - beforeEach -> - @EditorUpdatesController._applyUpdateFromDocumentUpdater @io, @doc_id, @update - - it "should send a version bump to the source client", -> - @sourceClient.emit - .calledWith("otUpdateApplied", v: @version, doc: @doc_id) - .should.equal true - @sourceClient.emit.calledOnce.should.equal true - - it "should get the clients connected to the document", -> - @io.sockets.clients - .calledWith(@doc_id) - .should.equal true - - it "should send the full update to the other clients", -> - for client in @otherClients - client.emit - .calledWith("otUpdateApplied", @update) - .should.equal true - - describe "with a duplicate op", -> - beforeEach -> - @update.dup = true - @EditorUpdatesController._applyUpdateFromDocumentUpdater @io, @doc_id, @update - - it "should send a version bump to the source client as usual", -> - @sourceClient.emit - .calledWith("otUpdateApplied", v: @version, doc: @doc_id) - .should.equal true - - it "should not send anything to the other clients (they've already had the op)", -> - for client in @otherClients - client.emit - .calledWith("otUpdateApplied") - .should.equal false - - describe "_processErrorFromDocumentUpdater", -> - beforeEach -> - @clients = [new MockClient(), new MockClient()] - @io.sockets = - clients: sinon.stub().returns(@clients) - @EditorUpdatesController._processErrorFromDocumentUpdater @io, @doc_id, "Something went wrong" - - it "should log a warning", -> - @logger.warn.called.should.equal true - - it "should disconnect all clients in that document", -> - @io.sockets.clients.calledWith(@doc_id).should.equal true - for client in @clients - client.disconnect.called.should.equal true - diff --git a/services/real-time/test/unit/coffee/DocumentUpdaterManagerTests.coffee b/services/real-time/test/unit/coffee/DocumentUpdaterManagerTests.coffee deleted file mode 100644 index aa4600d757..0000000000 --- a/services/real-time/test/unit/coffee/DocumentUpdaterManagerTests.coffee +++ /dev/null @@ -1,193 +0,0 @@ -require('chai').should() -sinon = require("sinon") -SandboxedModule = require('sandboxed-module') -path = require "path" -modulePath = '../../../app/js/DocumentUpdaterManager' - -describe 'DocumentUpdaterManager', -> - beforeEach -> - @project_id = "project-id-923" - @doc_id = "doc-id-394" - @lines = ["one", "two", "three"] - @version = 42 - @settings = - apis: documentupdater: url: "http://doc-updater.example.com" - redis: documentupdater: - key_schema: - pendingUpdates: ({doc_id}) -> "PendingUpdates:#{doc_id}" - maxUpdateSize: 7 * 1024 * 1024 - @rclient = {auth:->} - - @DocumentUpdaterManager = SandboxedModule.require modulePath, - requires: - 'settings-sharelatex':@settings - 'logger-sharelatex': @logger = {log: sinon.stub(), error: sinon.stub(), warn: sinon.stub()} - 'request': @request = {} - 'redis-sharelatex' : createClient: () => @rclient - 'metrics-sharelatex': @Metrics = - summary: sinon.stub() - Timer: class Timer - done: () -> - globals: - JSON: @JSON = Object.create(JSON) # avoid modifying JSON object directly - - describe "getDocument", -> - beforeEach -> - @callback = sinon.stub() - - describe "successfully", -> - beforeEach -> - @body = JSON.stringify - lines: @lines - version: @version - ops: @ops = ["mock-op-1", "mock-op-2"] - ranges: @ranges = {"mock": "ranges"} - @fromVersion = 2 - @request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body) - @DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback - - it 'should get the document from the document updater', -> - url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}?fromVersion=#{@fromVersion}" - @request.get.calledWith(url).should.equal true - - it "should call the callback with the lines, version, ranges and ops", -> - @callback.calledWith(null, @lines, @version, @ranges, @ops).should.equal true - - describe "when the document updater API returns an error", -> - beforeEach -> - @request.get = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null) - @DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback - - it "should return an error to the callback", -> - @callback.calledWith(@error).should.equal true - - [404, 422].forEach (statusCode) -> - describe "when the document updater returns a #{statusCode} status code", -> - beforeEach -> - @request.get = sinon.stub().callsArgWith(1, null, { statusCode }, "") - @DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback - - it "should return the callback with an error", -> - @callback.called.should.equal(true) - err = @callback.getCall(0).args[0] - err.should.have.property('statusCode', statusCode) - err.should.have.property('message', "doc updater could not load requested ops") - @logger.error.called.should.equal(false) - @logger.warn.called.should.equal(true) - - describe "when the document updater returns a failure error code", -> - beforeEach -> - @request.get = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "") - @DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback - - it "should return the callback with an error", -> - @callback.called.should.equal(true) - err = @callback.getCall(0).args[0] - err.should.have.property('statusCode', 500) - err.should.have.property('message', "doc updater returned a non-success status code: 500") - @logger.error.called.should.equal(true) - - describe 'flushProjectToMongoAndDelete', -> - beforeEach -> - @callback = sinon.stub() - - describe "successfully", -> - beforeEach -> - @request.del = sinon.stub().callsArgWith(1, null, {statusCode: 204}, "") - @DocumentUpdaterManager.flushProjectToMongoAndDelete @project_id, @callback - - it 'should delete the project from the document updater', -> - url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}?background=true" - @request.del.calledWith(url).should.equal true - - it "should call the callback with no error", -> - @callback.calledWith(null).should.equal true - - describe "when the document updater API returns an error", -> - beforeEach -> - @request.del = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null) - @DocumentUpdaterManager.flushProjectToMongoAndDelete @project_id, @callback - - it "should return an error to the callback", -> - @callback.calledWith(@error).should.equal true - - describe "when the document updater returns a failure error code", -> - beforeEach -> - @request.del = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "") - @DocumentUpdaterManager.flushProjectToMongoAndDelete @project_id, @callback - - it "should return the callback with an error", -> - @callback.called.should.equal(true) - err = @callback.getCall(0).args[0] - err.should.have.property('statusCode', 500) - err.should.have.property('message', "document updater returned a failure status code: 500") - - describe 'queueChange', -> - beforeEach -> - @change = { - "doc":"1234567890", - "op":["d":"test", "p":345] - "v": 789 - } - @rclient.rpush = sinon.stub().yields() - @callback = sinon.stub() - - describe "successfully", -> - beforeEach -> - @DocumentUpdaterManager.queueChange(@project_id, @doc_id, @change, @callback) - - it "should push the change", -> - @rclient.rpush - .calledWith("PendingUpdates:#{@doc_id}", JSON.stringify(@change)) - .should.equal true - - it "should notify the doc updater of the change via the pending-updates-list queue", -> - @rclient.rpush - .calledWith("pending-updates-list", "#{@project_id}:#{@doc_id}") - .should.equal true - - describe "with error talking to redis during rpush", -> - beforeEach -> - @rclient.rpush = sinon.stub().yields(new Error("something went wrong")) - @DocumentUpdaterManager.queueChange(@project_id, @doc_id, @change, @callback) - - it "should return an error", -> - @callback.calledWithExactly(sinon.match(Error)).should.equal true - - describe "with null byte corruption", -> - beforeEach -> - @JSON.stringify = () -> return '["bad bytes! \u0000 <- here"]' - @DocumentUpdaterManager.queueChange(@project_id, @doc_id, @change, @callback) - - it "should return an error", -> - @callback.calledWithExactly(sinon.match(Error)).should.equal true - - it "should not push the change onto the pending-updates-list queue", -> - @rclient.rpush.called.should.equal false - - describe "when the update is too large", -> - beforeEach -> - @change = {op: {p: 12,t: "update is too large".repeat(1024 * 400)}} - @DocumentUpdaterManager.queueChange(@project_id, @doc_id, @change, @callback) - - it "should return an error", -> - @callback.calledWithExactly(sinon.match(Error)).should.equal true - - it "should add the size to the error", -> - @callback.args[0][0].updateSize.should.equal 7782422 - - it "should not push the change onto the pending-updates-list queue", -> - @rclient.rpush.called.should.equal false - - describe "with invalid keys", -> - beforeEach -> - @change = { - "op":["d":"test", "p":345] - "version": 789 # not a valid key - } - @DocumentUpdaterManager.queueChange(@project_id, @doc_id, @change, @callback) - - it "should remove the invalid keys from the change", -> - @rclient.rpush - .calledWith("PendingUpdates:#{@doc_id}", JSON.stringify({op:@change.op})) - .should.equal true diff --git a/services/real-time/test/unit/coffee/DrainManagerTests.coffee b/services/real-time/test/unit/coffee/DrainManagerTests.coffee deleted file mode 100644 index 88009f02cd..0000000000 --- a/services/real-time/test/unit/coffee/DrainManagerTests.coffee +++ /dev/null @@ -1,81 +0,0 @@ -should = require('chai').should() -sinon = require "sinon" -SandboxedModule = require('sandboxed-module') -path = require "path" -modulePath = path.join __dirname, "../../../app/js/DrainManager" - -describe "DrainManager", -> - beforeEach -> - @DrainManager = SandboxedModule.require modulePath, requires: - "logger-sharelatex": @logger = log: sinon.stub() - @io = - sockets: - clients: sinon.stub() - - describe "startDrainTimeWindow", -> - beforeEach -> - @clients = [] - for i in [0..5399] - @clients[i] = { - id: i - emit: sinon.stub() - } - @io.sockets.clients.returns @clients - @DrainManager.startDrain = sinon.stub() - - it "should set a drain rate fast enough", (done)-> - @DrainManager.startDrainTimeWindow(@io, 9) - @DrainManager.startDrain.calledWith(@io, 10).should.equal true - done() - - - describe "reconnectNClients", -> - beforeEach -> - @clients = [] - for i in [0..9] - @clients[i] = { - id: i - emit: sinon.stub() - } - @io.sockets.clients.returns @clients - - describe "after first pass", -> - beforeEach -> - @DrainManager.reconnectNClients(@io, 3) - - it "should reconnect the first 3 clients", -> - for i in [0..2] - @clients[i].emit.calledWith("reconnectGracefully").should.equal true - - it "should not reconnect any more clients", -> - for i in [3..9] - @clients[i].emit.calledWith("reconnectGracefully").should.equal false - - describe "after second pass", -> - beforeEach -> - @DrainManager.reconnectNClients(@io, 3) - - it "should reconnect the next 3 clients", -> - for i in [3..5] - @clients[i].emit.calledWith("reconnectGracefully").should.equal true - - it "should not reconnect any more clients", -> - for i in [6..9] - @clients[i].emit.calledWith("reconnectGracefully").should.equal false - - it "should not reconnect the first 3 clients again", -> - for i in [0..2] - @clients[i].emit.calledOnce.should.equal true - - describe "after final pass", -> - beforeEach -> - @DrainManager.reconnectNClients(@io, 100) - - it "should not reconnect the first 6 clients again", -> - for i in [0..5] - @clients[i].emit.calledOnce.should.equal true - - it "should log out that it reached the end", -> - @logger.log - .calledWith("All clients have been told to reconnectGracefully") - .should.equal true diff --git a/services/real-time/test/unit/coffee/EventLoggerTests.coffee b/services/real-time/test/unit/coffee/EventLoggerTests.coffee deleted file mode 100644 index 93350af848..0000000000 --- a/services/real-time/test/unit/coffee/EventLoggerTests.coffee +++ /dev/null @@ -1,76 +0,0 @@ -require('chai').should() -expect = require("chai").expect -SandboxedModule = require('sandboxed-module') -modulePath = '../../../app/js/EventLogger' -sinon = require("sinon") -tk = require "timekeeper" - -describe 'EventLogger', -> - beforeEach -> - @start = Date.now() - tk.freeze(new Date(@start)) - @EventLogger = SandboxedModule.require modulePath, requires: - "logger-sharelatex": @logger = {error: sinon.stub(), warn: sinon.stub()} - "metrics-sharelatex": @metrics = {inc: sinon.stub()} - @channel = "applied-ops" - @id_1 = "random-hostname:abc-1" - @message_1 = "message-1" - @id_2 = "random-hostname:abc-2" - @message_2 = "message-2" - - afterEach -> - tk.reset() - - describe 'checkEventOrder', -> - - describe 'when the events are in order', -> - beforeEach -> - @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - @status = @EventLogger.checkEventOrder(@channel, @id_2, @message_2) - - it 'should accept events in order', -> - expect(@status).to.be.undefined - - it 'should increment the valid event metric', -> - @metrics.inc.calledWith("event.#{@channel}.valid", 1) - .should.equal.true - - describe 'when there is a duplicate events', -> - beforeEach -> - @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - @status = @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - - it 'should return "duplicate" for the same event', -> - expect(@status).to.equal "duplicate" - - it 'should increment the duplicate event metric', -> - @metrics.inc.calledWith("event.#{@channel}.duplicate", 1) - .should.equal.true - - describe 'when there are out of order events', -> - beforeEach -> - @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - @EventLogger.checkEventOrder(@channel, @id_2, @message_2) - @status = @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - - it 'should return "out-of-order" for the event', -> - expect(@status).to.equal "out-of-order" - - it 'should increment the out-of-order event metric', -> - @metrics.inc.calledWith("event.#{@channel}.out-of-order", 1) - .should.equal.true - - describe 'after MAX_STALE_TIME_IN_MS', -> - it 'should flush old entries', -> - @EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10 - @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - for i in [1..8] - status = @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - expect(status).to.equal "duplicate" - # the next event should flush the old entries aboce - @EventLogger.MAX_STALE_TIME_IN_MS=1000 - tk.freeze(new Date(@start + 5 * 1000)) - # because we flushed the entries this should not be a duplicate - @EventLogger.checkEventOrder(@channel, 'other-1', @message_2) - status = @EventLogger.checkEventOrder(@channel, @id_1, @message_1) - expect(status).to.be.undefined \ No newline at end of file diff --git a/services/real-time/test/unit/coffee/RoomManagerTests.coffee b/services/real-time/test/unit/coffee/RoomManagerTests.coffee deleted file mode 100644 index c81663576d..0000000000 --- a/services/real-time/test/unit/coffee/RoomManagerTests.coffee +++ /dev/null @@ -1,288 +0,0 @@ -chai = require('chai') -expect = chai.expect -should = chai.should() -sinon = require("sinon") -modulePath = "../../../app/js/RoomManager.js" -SandboxedModule = require('sandboxed-module') - -describe 'RoomManager', -> - beforeEach -> - @project_id = "project-id-123" - @doc_id = "doc-id-456" - @other_doc_id = "doc-id-789" - @client = {namespace: {name: ''}, id: "first-client"} - @RoomManager = SandboxedModule.require modulePath, requires: - "settings-sharelatex": @settings = {} - "logger-sharelatex": @logger = { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } - "metrics-sharelatex": @metrics = { gauge: sinon.stub() } - @RoomManager._clientsInRoom = sinon.stub() - @RoomManager._clientAlreadyInRoom = sinon.stub() - @RoomEvents = @RoomManager.eventSource() - sinon.spy(@RoomEvents, 'emit') - sinon.spy(@RoomEvents, 'once') - - describe "emitOnCompletion", -> - describe "when a subscribe errors", -> - afterEach () -> - process.removeListener("unhandledRejection", @onUnhandled) - - beforeEach (done) -> - @onUnhandled = (error) => - @unhandledError = error - done(new Error("unhandledRejection: #{error.message}")) - process.on("unhandledRejection", @onUnhandled) - - reject = undefined - subscribePromise = new Promise((_, r) -> reject = r) - promises = [subscribePromise] - eventName = "project-subscribed-123" - @RoomEvents.once eventName, () -> - setTimeout(done, 100) - @RoomManager.emitOnCompletion(promises, eventName) - setTimeout(() -> reject(new Error("subscribe failed"))) - - it "should keep going", () -> - expect(@unhandledError).to.not.exist - - describe "joinProject", -> - - describe "when the project room is empty", -> - - beforeEach (done) -> - @RoomManager._clientsInRoom - .withArgs(@client, @project_id) - .onFirstCall().returns(0) - @client.join = sinon.stub() - @callback = sinon.stub() - @RoomEvents.on 'project-active', (id) => - setTimeout () => - @RoomEvents.emit "project-subscribed-#{id}" - , 100 - @RoomManager.joinProject @client, @project_id, (err) => - @callback(err) - done() - - it "should emit a 'project-active' event with the id", -> - @RoomEvents.emit.calledWithExactly('project-active', @project_id).should.equal true - - it "should listen for the 'project-subscribed-id' event", -> - @RoomEvents.once.calledWith("project-subscribed-#{@project_id}").should.equal true - - it "should join the room using the id", -> - @client.join.calledWithExactly(@project_id).should.equal true - - describe "when there are other clients in the project room", -> - - beforeEach -> - @RoomManager._clientsInRoom - .withArgs(@client, @project_id) - .onFirstCall().returns(123) - .onSecondCall().returns(124) - @client.join = sinon.stub() - @RoomManager.joinProject @client, @project_id - - it "should join the room using the id", -> - @client.join.called.should.equal true - - it "should not emit any events", -> - @RoomEvents.emit.called.should.equal false - - - describe "joinDoc", -> - - describe "when the doc room is empty", -> - - beforeEach (done) -> - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onFirstCall().returns(0) - @client.join = sinon.stub() - @callback = sinon.stub() - @RoomEvents.on 'doc-active', (id) => - setTimeout () => - @RoomEvents.emit "doc-subscribed-#{id}" - , 100 - @RoomManager.joinDoc @client, @doc_id, (err) => - @callback(err) - done() - - it "should emit a 'doc-active' event with the id", -> - @RoomEvents.emit.calledWithExactly('doc-active', @doc_id).should.equal true - - it "should listen for the 'doc-subscribed-id' event", -> - @RoomEvents.once.calledWith("doc-subscribed-#{@doc_id}").should.equal true - - it "should join the room using the id", -> - @client.join.calledWithExactly(@doc_id).should.equal true - - describe "when there are other clients in the doc room", -> - - beforeEach -> - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onFirstCall().returns(123) - .onSecondCall().returns(124) - @client.join = sinon.stub() - @RoomManager.joinDoc @client, @doc_id - - it "should join the room using the id", -> - @client.join.called.should.equal true - - it "should not emit any events", -> - @RoomEvents.emit.called.should.equal false - - - describe "leaveDoc", -> - - describe "when doc room will be empty after this client has left", -> - - beforeEach -> - @RoomManager._clientAlreadyInRoom - .withArgs(@client, @doc_id) - .returns(true) - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onCall(0).returns(0) - @client.leave = sinon.stub() - @RoomManager.leaveDoc @client, @doc_id - - it "should leave the room using the id", -> - @client.leave.calledWithExactly(@doc_id).should.equal true - - it "should emit a 'doc-empty' event with the id", -> - @RoomEvents.emit.calledWithExactly('doc-empty', @doc_id).should.equal true - - - describe "when there are other clients in the doc room", -> - - beforeEach -> - @RoomManager._clientAlreadyInRoom - .withArgs(@client, @doc_id) - .returns(true) - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onCall(0).returns(123) - @client.leave = sinon.stub() - @RoomManager.leaveDoc @client, @doc_id - - it "should leave the room using the id", -> - @client.leave.calledWithExactly(@doc_id).should.equal true - - it "should not emit any events", -> - @RoomEvents.emit.called.should.equal false - - describe "when the client is not in the doc room", -> - - beforeEach -> - @RoomManager._clientAlreadyInRoom - .withArgs(@client, @doc_id) - .returns(false) - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onCall(0).returns(0) - @client.leave = sinon.stub() - @RoomManager.leaveDoc @client, @doc_id - - it "should not leave the room", -> - @client.leave.called.should.equal false - - it "should not emit any events", -> - @RoomEvents.emit.called.should.equal false - - - describe "leaveProjectAndDocs", -> - - describe "when the client is connected to the project and multiple docs", -> - - beforeEach -> - @RoomManager._roomsClientIsIn = sinon.stub().returns [@project_id, @doc_id, @other_doc_id] - @client.join = sinon.stub() - @client.leave = sinon.stub() - - describe "when this is the only client connected", -> - - beforeEach (done) -> - # first call is for the join, - # second for the leave - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onCall(0).returns(0) - .onCall(1).returns(0) - @RoomManager._clientsInRoom - .withArgs(@client, @other_doc_id) - .onCall(0).returns(0) - .onCall(1).returns(0) - @RoomManager._clientsInRoom - .withArgs(@client, @project_id) - .onCall(0).returns(0) - .onCall(1).returns(0) - @RoomManager._clientAlreadyInRoom - .withArgs(@client, @doc_id) - .returns(true) - .withArgs(@client, @other_doc_id) - .returns(true) - .withArgs(@client, @project_id) - .returns(true) - @RoomEvents.on 'project-active', (id) => - setTimeout () => - @RoomEvents.emit "project-subscribed-#{id}" - , 100 - @RoomEvents.on 'doc-active', (id) => - setTimeout () => - @RoomEvents.emit "doc-subscribed-#{id}" - , 100 - # put the client in the rooms - @RoomManager.joinProject @client, @project_id, () => - @RoomManager.joinDoc @client, @doc_id, () => - @RoomManager.joinDoc @client, @other_doc_id, () => - # now leave the project - @RoomManager.leaveProjectAndDocs @client - done() - - it "should leave all the docs", -> - @client.leave.calledWithExactly(@doc_id).should.equal true - @client.leave.calledWithExactly(@other_doc_id).should.equal true - - it "should leave the project", -> - @client.leave.calledWithExactly(@project_id).should.equal true - - it "should emit a 'doc-empty' event with the id for each doc", -> - @RoomEvents.emit.calledWithExactly('doc-empty', @doc_id).should.equal true - @RoomEvents.emit.calledWithExactly('doc-empty', @other_doc_id).should.equal true - - it "should emit a 'project-empty' event with the id for the project", -> - @RoomEvents.emit.calledWithExactly('project-empty', @project_id).should.equal true - - describe "when other clients are still connected", -> - - beforeEach -> - @RoomManager._clientsInRoom - .withArgs(@client, @doc_id) - .onFirstCall().returns(123) - .onSecondCall().returns(122) - @RoomManager._clientsInRoom - .withArgs(@client, @other_doc_id) - .onFirstCall().returns(123) - .onSecondCall().returns(122) - @RoomManager._clientsInRoom - .withArgs(@client, @project_id) - .onFirstCall().returns(123) - .onSecondCall().returns(122) - @RoomManager._clientAlreadyInRoom - .withArgs(@client, @doc_id) - .returns(true) - .withArgs(@client, @other_doc_id) - .returns(true) - .withArgs(@client, @project_id) - .returns(true) - @RoomManager.leaveProjectAndDocs @client - - it "should leave all the docs", -> - @client.leave.calledWithExactly(@doc_id).should.equal true - @client.leave.calledWithExactly(@other_doc_id).should.equal true - - it "should leave the project", -> - @client.leave.calledWithExactly(@project_id).should.equal true - - it "should not emit any events", -> - @RoomEvents.emit.called.should.equal false \ No newline at end of file diff --git a/services/real-time/test/unit/coffee/SafeJsonParseTest.coffee b/services/real-time/test/unit/coffee/SafeJsonParseTest.coffee deleted file mode 100644 index b652a2faae..0000000000 --- a/services/real-time/test/unit/coffee/SafeJsonParseTest.coffee +++ /dev/null @@ -1,34 +0,0 @@ -require('chai').should() -expect = require("chai").expect -SandboxedModule = require('sandboxed-module') -modulePath = '../../../app/js/SafeJsonParse' -sinon = require("sinon") - -describe 'SafeJsonParse', -> - beforeEach -> - @SafeJsonParse = SandboxedModule.require modulePath, requires: - "settings-sharelatex": @Settings = { - maxUpdateSize: 16 * 1024 - } - "logger-sharelatex": @logger = {error: sinon.stub()} - - describe "parse", -> - it "should parse documents correctly", (done) -> - @SafeJsonParse.parse '{"foo": "bar"}', (error, parsed) -> - expect(parsed).to.deep.equal {foo: "bar"} - done() - - it "should return an error on bad data", (done) -> - @SafeJsonParse.parse 'blah', (error, parsed) -> - expect(error).to.exist - done() - - it "should return an error on oversized data", (done) -> - # we have a 2k overhead on top of max size - big_blob = Array(16*1024).join("A") - data = "{\"foo\": \"#{big_blob}\"}" - @Settings.maxUpdateSize = 2 * 1024 - @SafeJsonParse.parse data, (error, parsed) => - @logger.error.called.should.equal true - expect(error).to.exist - done() \ No newline at end of file diff --git a/services/real-time/test/unit/coffee/SessionSocketsTests.coffee b/services/real-time/test/unit/coffee/SessionSocketsTests.coffee deleted file mode 100644 index 2f81699309..0000000000 --- a/services/real-time/test/unit/coffee/SessionSocketsTests.coffee +++ /dev/null @@ -1,126 +0,0 @@ -{EventEmitter} = require('events') -{expect} = require('chai') -SandboxedModule = require('sandboxed-module') -modulePath = '../../../app/js/SessionSockets' -sinon = require('sinon') - -describe 'SessionSockets', -> - before -> - @SessionSocketsModule = SandboxedModule.require modulePath - @io = new EventEmitter() - @id1 = Math.random().toString() - @id2 = Math.random().toString() - redisResponses = - error: [new Error('Redis: something went wrong'), null] - unknownId: [null, null] - redisResponses[@id1] = [null, {user: {_id: '123'}}] - redisResponses[@id2] = [null, {user: {_id: 'abc'}}] - - @sessionStore = - get: sinon.stub().callsFake (id, fn) -> - fn.apply(null, redisResponses[id]) - @cookieParser = (req, res, next) -> - req.signedCookies = req._signedCookies - next() - @SessionSockets = @SessionSocketsModule(@io, @sessionStore, @cookieParser, 'ol.sid') - @checkSocket = (socket, fn) => - @SessionSockets.once('connection', fn) - @io.emit('connection', socket) - - describe 'without cookies', -> - before -> - @socket = {handshake: {}} - - it 'should return a lookup error', (done) -> - @checkSocket @socket, (error) -> - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - done() - - it 'should not query redis', (done) -> - @checkSocket @socket, () => - expect(@sessionStore.get.called).to.equal(false) - done() - - describe 'with a different cookie', -> - before -> - @socket = {handshake: {_signedCookies: {other: 1}}} - - it 'should return a lookup error', (done) -> - @checkSocket @socket, (error) -> - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - done() - - it 'should not query redis', (done) -> - @checkSocket @socket, () => - expect(@sessionStore.get.called).to.equal(false) - done() - - describe 'with a valid cookie and a failing session lookup', -> - before -> - @socket = {handshake: {_signedCookies: {'ol.sid': 'error'}}} - - it 'should query redis', (done) -> - @checkSocket @socket, () => - expect(@sessionStore.get.called).to.equal(true) - done() - - it 'should return a redis error', (done) -> - @checkSocket @socket, (error) -> - expect(error).to.exist - expect(error.message).to.equal('Redis: something went wrong') - done() - - describe 'with a valid cookie and no matching session', -> - before -> - @socket = {handshake: {_signedCookies: {'ol.sid': 'unknownId'}}} - - it 'should query redis', (done) -> - @checkSocket @socket, () => - expect(@sessionStore.get.called).to.equal(true) - done() - - it 'should return a lookup error', (done) -> - @checkSocket @socket, (error) -> - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - done() - - describe 'with a valid cookie and a matching session', -> - before -> - @socket = {handshake: {_signedCookies: {'ol.sid': @id1}}} - - it 'should query redis', (done) -> - @checkSocket @socket, () => - expect(@sessionStore.get.called).to.equal(true) - done() - - it 'should not return an error', (done) -> - @checkSocket @socket, (error) -> - expect(error).to.not.exist - done() - - it 'should return the session', (done) -> - @checkSocket @socket, (error, s, session) -> - expect(session).to.deep.equal({user: {_id: '123'}}) - done() - - describe 'with a different valid cookie and matching session', -> - before -> - @socket = {handshake: {_signedCookies: {'ol.sid': @id2}}} - - it 'should query redis', (done) -> - @checkSocket @socket, () => - expect(@sessionStore.get.called).to.equal(true) - done() - - it 'should not return an error', (done) -> - @checkSocket @socket, (error) -> - expect(error).to.not.exist - done() - - it 'should return the other session', (done) -> - @checkSocket @socket, (error, s, session) -> - expect(session).to.deep.equal({user: {_id: 'abc'}}) - done() diff --git a/services/real-time/test/unit/coffee/WebApiManagerTests.coffee b/services/real-time/test/unit/coffee/WebApiManagerTests.coffee deleted file mode 100644 index e65ba93859..0000000000 --- a/services/real-time/test/unit/coffee/WebApiManagerTests.coffee +++ /dev/null @@ -1,84 +0,0 @@ -chai = require('chai') -should = chai.should() -sinon = require("sinon") -modulePath = "../../../app/js/WebApiManager.js" -SandboxedModule = require('sandboxed-module') -{ CodedError } = require('../../../app/js/Errors') - -describe 'WebApiManager', -> - beforeEach -> - @project_id = "project-id-123" - @user_id = "user-id-123" - @user = {_id: @user_id} - @callback = sinon.stub() - @WebApiManager = SandboxedModule.require modulePath, requires: - "request": @request = {} - "settings-sharelatex": @settings = - apis: - web: - url: "http://web.example.com" - user: "username" - pass: "password" - "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } - - describe "joinProject", -> - describe "successfully", -> - beforeEach -> - @response = { - project: { name: "Test project" } - privilegeLevel: "owner", - isRestrictedUser: true - } - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @response) - @WebApiManager.joinProject @project_id, @user, @callback - - it "should send a request to web to join the project", -> - @request.post - .calledWith({ - url: "#{@settings.apis.web.url}/project/#{@project_id}/join" - qs: - user_id: @user_id - auth: - user: @settings.apis.web.user - pass: @settings.apis.web.pass - sendImmediately: true - json: true - jar: false - headers: {} - }) - .should.equal true - - it "should return the project, privilegeLevel, and restricted flag", -> - @callback - .calledWith(null, @response.project, @response.privilegeLevel, @response.isRestrictedUser) - .should.equal true - - describe "with an error from web", -> - beforeEach -> - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 500}, null) - @WebApiManager.joinProject @project_id, @user_id, @callback - - it "should call the callback with an error", -> - @callback - .calledWith(sinon.match({message: "non-success status code from web: 500"})) - .should.equal true - - describe "with no data from web", -> - beforeEach -> - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200}, null) - @WebApiManager.joinProject @project_id, @user_id, @callback - - it "should call the callback with an error", -> - @callback - .calledWith(sinon.match({message: "no data returned from joinProject request"})) - .should.equal true - - describe "when the project is over its rate limit", -> - beforeEach -> - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 429}, null) - @WebApiManager.joinProject @project_id, @user_id, @callback - - it "should call the callback with a TooManyRequests error code", -> - @callback - .calledWith(sinon.match({message: "rate-limit hit when joining project", code: "TooManyRequests"})) - .should.equal true diff --git a/services/real-time/test/unit/coffee/WebsocketControllerTests.coffee b/services/real-time/test/unit/coffee/WebsocketControllerTests.coffee deleted file mode 100644 index c0047c49b7..0000000000 --- a/services/real-time/test/unit/coffee/WebsocketControllerTests.coffee +++ /dev/null @@ -1,872 +0,0 @@ -chai = require('chai') -should = chai.should() -sinon = require("sinon") -expect = chai.expect -modulePath = "../../../app/js/WebsocketController.js" -SandboxedModule = require('sandboxed-module') -tk = require "timekeeper" - -describe 'WebsocketController', -> - beforeEach -> - tk.freeze(new Date()) - @project_id = "project-id-123" - @user = { - _id: @user_id = "user-id-123" - first_name: "James" - last_name: "Allen" - email: "james@example.com" - signUpDate: new Date("2014-01-01") - loginCount: 42 - } - @callback = sinon.stub() - @client = - disconnected: false - id: @client_id = "mock-client-id-123" - publicId: "other-id-#{Math.random()}" - ol_context: {} - join: sinon.stub() - leave: sinon.stub() - @WebsocketController = SandboxedModule.require modulePath, requires: - "./WebApiManager": @WebApiManager = {} - "./AuthorizationManager": @AuthorizationManager = {} - "./DocumentUpdaterManager": @DocumentUpdaterManager = {} - "./ConnectedUsersManager": @ConnectedUsersManager = {} - "./WebsocketLoadBalancer": @WebsocketLoadBalancer = {} - "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } - "metrics-sharelatex": @metrics = - inc: sinon.stub() - set: sinon.stub() - "./RoomManager": @RoomManager = {} - - afterEach -> - tk.reset() - - describe "joinProject", -> - describe "when authorised", -> - beforeEach -> - @client.id = "mock-client-id" - @project = { - name: "Test Project" - owner: { - _id: @owner_id = "mock-owner-id-123" - } - } - @privilegeLevel = "owner" - @ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4) - @isRestrictedUser = true - @WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, @project, @privilegeLevel, @isRestrictedUser) - @RoomManager.joinProject = sinon.stub().callsArg(2) - @WebsocketController.joinProject @client, @user, @project_id, @callback - - it "should load the project from web", -> - @WebApiManager.joinProject - .calledWith(@project_id, @user) - .should.equal true - - it "should join the project room", -> - @RoomManager.joinProject.calledWith(@client, @project_id).should.equal true - - it "should set the privilege level on the client", -> - @client.ol_context["privilege_level"].should.equal @privilegeLevel - it "should set the user's id on the client", -> - @client.ol_context["user_id"].should.equal @user._id - it "should set the user's email on the client", -> - @client.ol_context["email"].should.equal @user.email - it "should set the user's first_name on the client", -> - @client.ol_context["first_name"].should.equal @user.first_name - it "should set the user's last_name on the client", -> - @client.ol_context["last_name"].should.equal @user.last_name - it "should set the user's sign up date on the client", -> - @client.ol_context["signup_date"].should.equal @user.signUpDate - it "should set the user's login_count on the client", -> - @client.ol_context["login_count"].should.equal @user.loginCount - it "should set the connected time on the client", -> - @client.ol_context["connected_time"].should.equal new Date() - it "should set the project_id on the client", -> - @client.ol_context["project_id"].should.equal @project_id - it "should set the project owner id on the client", -> - @client.ol_context["owner_id"].should.equal @owner_id - it "should set the is_restricted_user flag on the client", -> - @client.ol_context["is_restricted_user"].should.equal @isRestrictedUser - it "should call the callback with the project, privilegeLevel and protocolVersion", -> - @callback - .calledWith(null, @project, @privilegeLevel, @WebsocketController.PROTOCOL_VERSION) - .should.equal true - - it "should mark the user as connected in ConnectedUsersManager", -> - @ConnectedUsersManager.updateUserPosition - .calledWith(@project_id, @client.publicId, @user, null) - .should.equal true - - it "should increment the join-project metric", -> - @metrics.inc.calledWith("editor.join-project").should.equal true - - describe "when not authorized", -> - beforeEach -> - @WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, null, null) - @WebsocketController.joinProject @client, @user, @project_id, @callback - - it "should return an error", -> - @callback - .calledWith(sinon.match({message: "not authorized"})) - .should.equal true - - it "should not log an error", -> - @logger.error.called.should.equal false - - describe "when the subscribe failed", -> - beforeEach -> - @client.id = "mock-client-id" - @project = { - name: "Test Project" - owner: { - _id: @owner_id = "mock-owner-id-123" - } - } - @privilegeLevel = "owner" - @ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4) - @isRestrictedUser = true - @WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, @project, @privilegeLevel, @isRestrictedUser) - @RoomManager.joinProject = sinon.stub().callsArgWith(2, new Error("subscribe failed")) - @WebsocketController.joinProject @client, @user, @project_id, @callback - - it "should return an error", -> - @callback - .calledWith(sinon.match({message: "subscribe failed"})) - .should.equal true - @callback.args[0][0].message.should.equal "subscribe failed" - - describe "when the client has disconnected", -> - beforeEach -> - @client.disconnected = true - @WebApiManager.joinProject = sinon.stub().callsArg(2) - @WebsocketController.joinProject @client, @user, @project_id, @callback - - it "should not call WebApiManager.joinProject", -> - expect(@WebApiManager.joinProject.called).to.equal(false) - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal [] - - it "should increment the editor.join-project.disconnected metric with a status", -> - expect(@metrics.inc.calledWith('editor.join-project.disconnected', 1, {status: 'immediately'})).to.equal(true) - - describe "when the client disconnects while WebApiManager.joinProject is running", -> - beforeEach -> - @WebApiManager.joinProject = (project, user, cb) => - @client.disconnected = true - cb(null, @project, @privilegeLevel, @isRestrictedUser) - - @WebsocketController.joinProject @client, @user, @project_id, @callback - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal [] - - it "should increment the editor.join-project.disconnected metric with a status", -> - expect(@metrics.inc.calledWith('editor.join-project.disconnected', 1, {status: 'after-web-api-call'})).to.equal(true) - - describe "leaveProject", -> - beforeEach -> - @DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon.stub().callsArg(1) - @ConnectedUsersManager.markUserAsDisconnected = sinon.stub().callsArg(2) - @WebsocketLoadBalancer.emitToRoom = sinon.stub() - @RoomManager.leaveProjectAndDocs = sinon.stub() - @clientsInRoom = [] - @io = - sockets: - clients: (room_id) => - if room_id != @project_id - throw "expected room_id to be project_id" - return @clientsInRoom - @client.ol_context.project_id = @project_id - @client.ol_context.user_id = @user_id - @WebsocketController.FLUSH_IF_EMPTY_DELAY = 0 - tk.reset() # Allow setTimeout to work. - - describe "when the client did not joined a project yet", -> - beforeEach (done) -> - @client.ol_context = {} - @WebsocketController.leaveProject @io, @client, done - - it "should bail out when calling leaveProject", () -> - @WebsocketLoadBalancer.emitToRoom.called.should.equal false - @RoomManager.leaveProjectAndDocs.called.should.equal false - @ConnectedUsersManager.markUserAsDisconnected.called.should.equal false - - it "should not inc any metric", () -> - @metrics.inc.called.should.equal false - - describe "when the project is empty", -> - beforeEach (done) -> - @clientsInRoom = [] - @WebsocketController.leaveProject @io, @client, done - - it "should end clientTracking.clientDisconnected to the project room", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith(@project_id, "clientTracking.clientDisconnected", @client.publicId) - .should.equal true - - it "should mark the user as disconnected", -> - @ConnectedUsersManager.markUserAsDisconnected - .calledWith(@project_id, @client.publicId) - .should.equal true - - it "should flush the project in the document updater", -> - @DocumentUpdaterManager.flushProjectToMongoAndDelete - .calledWith(@project_id) - .should.equal true - - it "should increment the leave-project metric", -> - @metrics.inc.calledWith("editor.leave-project").should.equal true - - it "should track the disconnection in RoomManager", -> - @RoomManager.leaveProjectAndDocs - .calledWith(@client) - .should.equal true - - describe "when the project is not empty", -> - beforeEach -> - @clientsInRoom = ["mock-remaining-client"] - @WebsocketController.leaveProject @io, @client - - it "should not flush the project in the document updater", -> - @DocumentUpdaterManager.flushProjectToMongoAndDelete - .called.should.equal false - - describe "when client has not authenticated", -> - beforeEach (done) -> - @client.ol_context.user_id = null - @client.ol_context.project_id = null - @WebsocketController.leaveProject @io, @client, done - - it "should not end clientTracking.clientDisconnected to the project room", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith(@project_id, "clientTracking.clientDisconnected", @client.publicId) - .should.equal false - - it "should not mark the user as disconnected", -> - @ConnectedUsersManager.markUserAsDisconnected - .calledWith(@project_id, @client.publicId) - .should.equal false - - it "should not flush the project in the document updater", -> - @DocumentUpdaterManager.flushProjectToMongoAndDelete - .calledWith(@project_id) - .should.equal false - - it "should not increment the leave-project metric", -> - @metrics.inc.calledWith("editor.leave-project").should.equal false - - describe "when client has not joined a project", -> - beforeEach (done) -> - @client.ol_context.user_id = @user_id - @client.ol_context.project_id = null - @WebsocketController.leaveProject @io, @client, done - - it "should not end clientTracking.clientDisconnected to the project room", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith(@project_id, "clientTracking.clientDisconnected", @client.publicId) - .should.equal false - - it "should not mark the user as disconnected", -> - @ConnectedUsersManager.markUserAsDisconnected - .calledWith(@project_id, @client.publicId) - .should.equal false - - it "should not flush the project in the document updater", -> - @DocumentUpdaterManager.flushProjectToMongoAndDelete - .calledWith(@project_id) - .should.equal false - - it "should not increment the leave-project metric", -> - @metrics.inc.calledWith("editor.leave-project").should.equal false - - describe "joinDoc", -> - beforeEach -> - @doc_id = "doc-id-123" - @doc_lines = ["doc", "lines"] - @version = 42 - @ops = ["mock", "ops"] - @ranges = { "mock": "ranges" } - @options = {} - - @client.ol_context.project_id = @project_id - @client.ol_context.is_restricted_user = false - @AuthorizationManager.addAccessToDoc = sinon.stub() - @AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null) - @DocumentUpdaterManager.getDocument = sinon.stub().callsArgWith(3, null, @doc_lines, @version, @ranges, @ops) - @RoomManager.joinDoc = sinon.stub().callsArg(2) - - describe "works", -> - beforeEach -> - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should check that the client is authorized to view the project", -> - @AuthorizationManager.assertClientCanViewProject - .calledWith(@client) - .should.equal true - - it "should get the document from the DocumentUpdaterManager with fromVersion", -> - @DocumentUpdaterManager.getDocument - .calledWith(@project_id, @doc_id, -1) - .should.equal true - - it "should add permissions for the client to access the doc", -> - @AuthorizationManager.addAccessToDoc - .calledWith(@client, @doc_id) - .should.equal true - - it "should join the client to room for the doc_id", -> - @RoomManager.joinDoc - .calledWith(@client, @doc_id) - .should.equal true - - it "should call the callback with the lines, version, ranges and ops", -> - @callback - .calledWith(null, @doc_lines, @version, @ops, @ranges) - .should.equal true - - it "should increment the join-doc metric", -> - @metrics.inc.calledWith("editor.join-doc").should.equal true - - describe "with a fromVersion", -> - beforeEach -> - @fromVersion = 40 - @WebsocketController.joinDoc @client, @doc_id, @fromVersion, @options, @callback - - it "should get the document from the DocumentUpdaterManager with fromVersion", -> - @DocumentUpdaterManager.getDocument - .calledWith(@project_id, @doc_id, @fromVersion) - .should.equal true - - describe "with doclines that need escaping", -> - beforeEach -> - @doc_lines.push ["räksmörgås"] - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should call the callback with the escaped lines", -> - escaped_lines = @callback.args[0][1] - escaped_word = escaped_lines.pop() - escaped_word.should.equal 'räksmörgÃ¥s' - # Check that unescaping works - decodeURIComponent(escape(escaped_word)).should.equal "räksmörgås" - - describe "with comments that need encoding", -> - beforeEach -> - @ranges.comments = [{ op: { c: "räksmörgås" } }] - @WebsocketController.joinDoc @client, @doc_id, -1, { encodeRanges: true }, @callback - - it "should call the callback with the encoded comment", -> - encoded_comments = @callback.args[0][4] - encoded_comment = encoded_comments.comments.pop() - encoded_comment_text = encoded_comment.op.c - encoded_comment_text.should.equal 'räksmörgÃ¥s' - - describe "with changes that need encoding", -> - it "should call the callback with the encoded insert change", -> - @ranges.changes = [{ op: { i: "räksmörgås" } }] - @WebsocketController.joinDoc @client, @doc_id, -1, { encodeRanges: true }, @callback - - encoded_changes = @callback.args[0][4] - encoded_change = encoded_changes.changes.pop() - encoded_change_text = encoded_change.op.i - encoded_change_text.should.equal 'räksmörgÃ¥s' - - it "should call the callback with the encoded delete change", -> - @ranges.changes = [{ op: { d: "räksmörgås" } }] - @WebsocketController.joinDoc @client, @doc_id, -1, { encodeRanges: true }, @callback - - encoded_changes = @callback.args[0][4] - encoded_change = encoded_changes.changes.pop() - encoded_change_text = encoded_change.op.d - encoded_change_text.should.equal 'räksmörgÃ¥s' - - describe "when not authorized", -> - beforeEach -> - @AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, @err = new Error("not authorized")) - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should call the callback with an error", -> - @callback.calledWith(sinon.match({message: "not authorized"})).should.equal true - - it "should not call the DocumentUpdaterManager", -> - @DocumentUpdaterManager.getDocument.called.should.equal false - - describe "with a restricted client", -> - beforeEach -> - @ranges.comments = [{op: {a: 1}}, {op: {a: 2}}] - @client.ol_context.is_restricted_user = true - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should overwrite ranges.comments with an empty list", -> - ranges = @callback.args[0][4] - expect(ranges.comments).to.deep.equal [] - - describe "when the client has disconnected", -> - beforeEach -> - @client.disconnected = true - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal([]) - - it "should increment the editor.join-doc.disconnected metric with a status", -> - expect(@metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'immediately'})).to.equal(true) - - it "should not get the document", -> - expect(@DocumentUpdaterManager.getDocument.called).to.equal(false) - - describe "when the client disconnects while RoomManager.joinDoc is running", -> - beforeEach -> - @RoomManager.joinDoc = (client, doc_id, cb) => - @client.disconnected = true - cb() - - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal([]) - - it "should increment the editor.join-doc.disconnected metric with a status", -> - expect(@metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'after-joining-room'})).to.equal(true) - - it "should not get the document", -> - expect(@DocumentUpdaterManager.getDocument.called).to.equal(false) - - describe "when the client disconnects while DocumentUpdaterManager.getDocument is running", -> - beforeEach -> - @DocumentUpdaterManager.getDocument = (project_id, doc_id, fromVersion, callback) => - @client.disconnected = true - callback(null, @doc_lines, @version, @ranges, @ops) - - @WebsocketController.joinDoc @client, @doc_id, -1, @options, @callback - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal [] - - it "should increment the editor.join-doc.disconnected metric with a status", -> - expect(@metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'after-doc-updater-call'})).to.equal(true) - - describe "leaveDoc", -> - beforeEach -> - @doc_id = "doc-id-123" - @client.ol_context.project_id = @project_id - @RoomManager.leaveDoc = sinon.stub() - @WebsocketController.leaveDoc @client, @doc_id, @callback - - it "should remove the client from the doc_id room", -> - @RoomManager.leaveDoc - .calledWith(@client, @doc_id).should.equal true - - it "should call the callback", -> - @callback.called.should.equal true - - it "should increment the leave-doc metric", -> - @metrics.inc.calledWith("editor.leave-doc").should.equal true - - describe "getConnectedUsers", -> - beforeEach -> - @client.ol_context.project_id = @project_id - @users = ["mock", "users"] - @WebsocketLoadBalancer.emitToRoom = sinon.stub() - @ConnectedUsersManager.getConnectedUsers = sinon.stub().callsArgWith(1, null, @users) - - describe "when authorized", -> - beforeEach (done) -> - @AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null) - @WebsocketController.getConnectedUsers @client, (args...) => - @callback(args...) - done() - - it "should check that the client is authorized to view the project", -> - @AuthorizationManager.assertClientCanViewProject - .calledWith(@client) - .should.equal true - - it "should broadcast a request to update the client list", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith(@project_id, "clientTracking.refresh") - .should.equal true - - it "should get the connected users for the project", -> - @ConnectedUsersManager.getConnectedUsers - .calledWith(@project_id) - .should.equal true - - it "should return the users", -> - @callback.calledWith(null, @users).should.equal true - - it "should increment the get-connected-users metric", -> - @metrics.inc.calledWith("editor.get-connected-users").should.equal true - - describe "when not authorized", -> - beforeEach -> - @AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, @err = new Error("not authorized")) - @WebsocketController.getConnectedUsers @client, @callback - - it "should not get the connected users for the project", -> - @ConnectedUsersManager.getConnectedUsers - .called - .should.equal false - - it "should return an error", -> - @callback.calledWith(@err).should.equal true - - describe "when restricted user", -> - beforeEach -> - @client.ol_context.is_restricted_user = true - @AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null) - @WebsocketController.getConnectedUsers @client, @callback - - it "should return an empty array of users", -> - @callback.calledWith(null, []).should.equal true - - it "should not get the connected users for the project", -> - @ConnectedUsersManager.getConnectedUsers - .called - .should.equal false - - describe "when the client has disconnected", -> - beforeEach -> - @client.disconnected = true - @AuthorizationManager.assertClientCanViewProject = sinon.stub() - @WebsocketController.getConnectedUsers @client, @callback - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal([]) - - it "should not check permissions", -> - expect(@AuthorizationManager.assertClientCanViewProject.called).to.equal(false) - - describe "updateClientPosition", -> - beforeEach -> - @WebsocketLoadBalancer.emitToRoom = sinon.stub() - @ConnectedUsersManager.updateUserPosition = sinon.stub().callsArgWith(4) - @AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub().callsArgWith(2, null) - @update = { - doc_id: @doc_id = "doc-id-123" - row: @row = 42 - column: @column = 37 - } - - describe "with a logged in user", -> - beforeEach -> - @client.ol_context = { - project_id: @project_id - first_name: @first_name = "Douglas" - last_name: @last_name = "Adams" - email: @email = "joe@example.com" - user_id: @user_id = "user-id-123" - } - @WebsocketController.updateClientPosition @client, @update - - @populatedCursorData = - doc_id: @doc_id, - id: @client.publicId - name: "#{@first_name} #{@last_name}" - row: @row - column: @column - email: @email - user_id: @user_id - - it "should send the update to the project room with the user's name", -> - @WebsocketLoadBalancer.emitToRoom.calledWith(@project_id, "clientTracking.clientUpdated", @populatedCursorData).should.equal true - - it "should send the cursor data to the connected user manager", (done)-> - @ConnectedUsersManager.updateUserPosition.calledWith(@project_id, @client.publicId, { - _id: @user_id, - email: @email, - first_name: @first_name, - last_name: @last_name - }, { - row: @row - column: @column - doc_id: @doc_id - }).should.equal true - done() - - it "should increment the update-client-position metric at 0.1 frequency", -> - @metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal true - - describe "with a logged in user who has no last_name set", -> - beforeEach -> - @client.ol_context = { - project_id: @project_id - first_name: @first_name = "Douglas" - last_name: undefined - email: @email = "joe@example.com" - user_id: @user_id = "user-id-123" - } - @WebsocketController.updateClientPosition @client, @update - - @populatedCursorData = - doc_id: @doc_id, - id: @client.publicId - name: "#{@first_name}" - row: @row - column: @column - email: @email - user_id: @user_id - - it "should send the update to the project room with the user's name", -> - @WebsocketLoadBalancer.emitToRoom.calledWith(@project_id, "clientTracking.clientUpdated", @populatedCursorData).should.equal true - - it "should send the cursor data to the connected user manager", (done)-> - @ConnectedUsersManager.updateUserPosition.calledWith(@project_id, @client.publicId, { - _id: @user_id, - email: @email, - first_name: @first_name, - last_name: undefined - }, { - row: @row - column: @column - doc_id: @doc_id - }).should.equal true - done() - - it "should increment the update-client-position metric at 0.1 frequency", -> - @metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal true - - describe "with a logged in user who has no first_name set", -> - beforeEach -> - @client.ol_context = { - project_id: @project_id - first_name: undefined - last_name: @last_name = "Adams" - email: @email = "joe@example.com" - user_id: @user_id = "user-id-123" - } - @WebsocketController.updateClientPosition @client, @update - - @populatedCursorData = - doc_id: @doc_id, - id: @client.publicId - name: "#{@last_name}" - row: @row - column: @column - email: @email - user_id: @user_id - - it "should send the update to the project room with the user's name", -> - @WebsocketLoadBalancer.emitToRoom.calledWith(@project_id, "clientTracking.clientUpdated", @populatedCursorData).should.equal true - - it "should send the cursor data to the connected user manager", (done)-> - @ConnectedUsersManager.updateUserPosition.calledWith(@project_id, @client.publicId, { - _id: @user_id, - email: @email, - first_name: undefined, - last_name: @last_name - }, { - row: @row - column: @column - doc_id: @doc_id - }).should.equal true - done() - - it "should increment the update-client-position metric at 0.1 frequency", -> - @metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal true - describe "with a logged in user who has no names set", -> - beforeEach -> - @client.ol_context = { - project_id: @project_id - first_name: undefined - last_name: undefined - email: @email = "joe@example.com" - user_id: @user_id = "user-id-123" - } - @WebsocketController.updateClientPosition @client, @update - - it "should send the update to the project name with no name", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith(@project_id, "clientTracking.clientUpdated", { - doc_id: @doc_id, - id: @client.publicId, - user_id: @user_id, - name: "", - row: @row, - column: @column, - email: @email - }) - .should.equal true - - - describe "with an anonymous user", -> - beforeEach -> - @client.ol_context = { - project_id: @project_id - } - @WebsocketController.updateClientPosition @client, @update - - it "should send the update to the project room with no name", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith(@project_id, "clientTracking.clientUpdated", { - doc_id: @doc_id, - id: @client.publicId - name: "" - row: @row - column: @column - }) - .should.equal true - - it "should not send cursor data to the connected user manager", (done)-> - @ConnectedUsersManager.updateUserPosition.called.should.equal false - done() - - describe "when the client has disconnected", -> - beforeEach -> - @client.disconnected = true - @AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub() - @WebsocketController.updateClientPosition @client, @update, @callback - - it "should call the callback with no details", -> - expect(@callback.args[0]).to.deep.equal([]) - - it "should not check permissions", -> - expect(@AuthorizationManager.assertClientCanViewProjectAndDoc.called).to.equal(false) - - describe "applyOtUpdate", -> - beforeEach -> - @update = {op: {p: 12, t: "foo"}} - @client.ol_context.user_id = @user_id - @client.ol_context.project_id = @project_id - @WebsocketController._assertClientCanApplyUpdate = sinon.stub().yields() - @DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3) - - describe "succesfully", -> - beforeEach -> - @WebsocketController.applyOtUpdate @client, @doc_id, @update, @callback - - it "should set the source of the update to the client id", -> - @update.meta.source.should.equal @client.publicId - - it "should set the user_id of the update to the user id", -> - @update.meta.user_id.should.equal @user_id - - it "should queue the update", -> - @DocumentUpdaterManager.queueChange - .calledWith(@project_id, @doc_id, @update) - .should.equal true - - it "should call the callback", -> - @callback.called.should.equal true - - it "should increment the doc updates", -> - @metrics.inc.calledWith("editor.doc-update").should.equal true - - describe "unsuccessfully", -> - beforeEach -> - @client.disconnect = sinon.stub() - @DocumentUpdaterManager.queueChange = sinon.stub().callsArgWith(3, @error = new Error("Something went wrong")) - @WebsocketController.applyOtUpdate @client, @doc_id, @update, @callback - - it "should disconnect the client", -> - @client.disconnect.called.should.equal true - - it "should log an error", -> - @logger.error.called.should.equal true - - it "should call the callback with the error", -> - @callback.calledWith(@error).should.equal true - - describe "when not authorized", -> - beforeEach -> - @client.disconnect = sinon.stub() - @WebsocketController._assertClientCanApplyUpdate = sinon.stub().yields(@error = new Error("not authorized")) - @WebsocketController.applyOtUpdate @client, @doc_id, @update, @callback - - # This happens in a setTimeout to allow the client a chance to receive the error first. - # I'm not sure how to unit test, but it is acceptance tested. - # it "should disconnect the client", -> - # @client.disconnect.called.should.equal true - - it "should log a warning", -> - @logger.warn.called.should.equal true - - it "should call the callback with the error", -> - @callback.calledWith(@error).should.equal true - - describe "update_too_large", -> - beforeEach (done) -> - @client.disconnect = sinon.stub() - @client.emit = sinon.stub() - @client.ol_context.user_id = @user_id - @client.ol_context.project_id = @project_id - error = new Error("update is too large") - error.updateSize = 7372835 - @DocumentUpdaterManager.queueChange = sinon.stub().callsArgWith(3, error) - @WebsocketController.applyOtUpdate @client, @doc_id, @update, @callback - setTimeout -> - done() - , 1 - - it "should call the callback with no error", -> - @callback.called.should.equal true - @callback.args[0].should.deep.equal [] - - it "should log a warning with the size and context", -> - @logger.warn.called.should.equal true - @logger.warn.args[0].should.deep.equal [{ - @user_id, @project_id, @doc_id, updateSize: 7372835 - }, 'update is too large'] - - describe "after 100ms", -> - beforeEach (done) -> - setTimeout done, 100 - - it "should send an otUpdateError the client", -> - @client.emit.calledWith('otUpdateError').should.equal true - - it "should disconnect the client", -> - @client.disconnect.called.should.equal true - - describe "when the client disconnects during the next 100ms", -> - beforeEach (done) -> - @client.disconnected = true - setTimeout done, 100 - - it "should not send an otUpdateError the client", -> - @client.emit.calledWith('otUpdateError').should.equal false - - it "should not disconnect the client", -> - @client.disconnect.called.should.equal false - - it "should increment the editor.doc-update.disconnected metric with a status", -> - expect(@metrics.inc.calledWith('editor.doc-update.disconnected', 1, {status:'at-otUpdateError'})).to.equal(true) - - describe "_assertClientCanApplyUpdate", -> - beforeEach -> - @edit_update = { op: [{i: "foo", p: 42}, {c: "bar", p: 132}] } # comments may still be in an edit op - @comment_update = { op: [{c: "bar", p: 132}] } - @AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub() - @AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub() - - describe "with a read-write client", -> - it "should return successfully", (done) -> - @AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null) - @WebsocketController._assertClientCanApplyUpdate @client, @doc_id, @edit_update, (error) -> - expect(error).to.be.null - done() - - describe "with a read-only client and an edit op", -> - it "should return an error", (done) -> - @AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized")) - @AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) - @WebsocketController._assertClientCanApplyUpdate @client, @doc_id, @edit_update, (error) -> - expect(error.message).to.equal "not authorized" - done() - - describe "with a read-only client and a comment op", -> - it "should return successfully", (done) -> - @AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized")) - @AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) - @WebsocketController._assertClientCanApplyUpdate @client, @doc_id, @comment_update, (error) -> - expect(error).to.be.null - done() - - describe "with a totally unauthorized client", -> - it "should return an error", (done) -> - @AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized")) - @AuthorizationManager.assertClientCanViewProjectAndDoc.yields(new Error("not authorized")) - @WebsocketController._assertClientCanApplyUpdate @client, @doc_id, @comment_update, (error) -> - expect(error.message).to.equal "not authorized" - done() diff --git a/services/real-time/test/unit/coffee/WebsocketLoadBalancerTests.coffee b/services/real-time/test/unit/coffee/WebsocketLoadBalancerTests.coffee deleted file mode 100644 index b2441cd6d0..0000000000 --- a/services/real-time/test/unit/coffee/WebsocketLoadBalancerTests.coffee +++ /dev/null @@ -1,161 +0,0 @@ -SandboxedModule = require('sandboxed-module') -sinon = require('sinon') -require('chai').should() -modulePath = require('path').join __dirname, '../../../app/js/WebsocketLoadBalancer' - -describe "WebsocketLoadBalancer", -> - beforeEach -> - @rclient = {} - @RoomEvents = {on: sinon.stub()} - @WebsocketLoadBalancer = SandboxedModule.require modulePath, requires: - "./RedisClientManager": - createClientList: () => [] - "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } - "./SafeJsonParse": @SafeJsonParse = - parse: (data, cb) => cb null, JSON.parse(data) - "./EventLogger": {checkEventOrder: sinon.stub()} - "./HealthCheckManager": {check: sinon.stub()} - "./RoomManager" : @RoomManager = {eventSource: sinon.stub().returns @RoomEvents} - "./ChannelManager": @ChannelManager = {publish: sinon.stub()} - "./ConnectedUsersManager": @ConnectedUsersManager = {refreshClient: sinon.stub()} - @io = {} - @WebsocketLoadBalancer.rclientPubList = [{publish: sinon.stub()}] - @WebsocketLoadBalancer.rclientSubList = [{ - subscribe: sinon.stub() - on: sinon.stub() - }] - - @room_id = "room-id" - @message = "otUpdateApplied" - @payload = ["argument one", 42] - - describe "emitToRoom", -> - beforeEach -> - @WebsocketLoadBalancer.emitToRoom(@room_id, @message, @payload...) - - it "should publish the message to redis", -> - @ChannelManager.publish - .calledWith(@WebsocketLoadBalancer.rclientPubList[0], "editor-events", @room_id, JSON.stringify( - room_id: @room_id, - message: @message - payload: @payload - )) - .should.equal true - - describe "emitToAll", -> - beforeEach -> - @WebsocketLoadBalancer.emitToRoom = sinon.stub() - @WebsocketLoadBalancer.emitToAll @message, @payload... - - it "should emit to the room 'all'", -> - @WebsocketLoadBalancer.emitToRoom - .calledWith("all", @message, @payload...) - .should.equal true - - describe "listenForEditorEvents", -> - beforeEach -> - @WebsocketLoadBalancer._processEditorEvent = sinon.stub() - @WebsocketLoadBalancer.listenForEditorEvents() - - it "should subscribe to the editor-events channel", -> - @WebsocketLoadBalancer.rclientSubList[0].subscribe - .calledWith("editor-events") - .should.equal true - - it "should process the events with _processEditorEvent", -> - @WebsocketLoadBalancer.rclientSubList[0].on - .calledWith("message", sinon.match.func) - .should.equal true - - describe "_processEditorEvent", -> - describe "with bad JSON", -> - beforeEach -> - @isRestrictedUser = false - @SafeJsonParse.parse = sinon.stub().callsArgWith 1, new Error("oops") - @WebsocketLoadBalancer._processEditorEvent(@io, "editor-events", "blah") - - it "should log an error", -> - @logger.error.called.should.equal true - - describe "with a designated room", -> - beforeEach -> - @io.sockets = - clients: sinon.stub().returns([ - {id: 'client-id-1', emit: @emit1 = sinon.stub(), ol_context: {}} - {id: 'client-id-2', emit: @emit2 = sinon.stub(), ol_context: {}} - {id: 'client-id-1', emit: @emit3 = sinon.stub(), ol_context: {}} # duplicate client - ]) - data = JSON.stringify - room_id: @room_id - message: @message - payload: @payload - @WebsocketLoadBalancer._processEditorEvent(@io, "editor-events", data) - - it "should send the message to all (unique) clients in the room", -> - @io.sockets.clients - .calledWith(@room_id) - .should.equal true - @emit1.calledWith(@message, @payload...).should.equal true - @emit2.calledWith(@message, @payload...).should.equal true - @emit3.called.should.equal false # duplicate client should be ignored - - describe "with a designated room, and restricted clients, not restricted message", -> - beforeEach -> - @io.sockets = - clients: sinon.stub().returns([ - {id: 'client-id-1', emit: @emit1 = sinon.stub(), ol_context: {}} - {id: 'client-id-2', emit: @emit2 = sinon.stub(), ol_context: {}} - {id: 'client-id-1', emit: @emit3 = sinon.stub(), ol_context: {}} # duplicate client - {id: 'client-id-4', emit: @emit4 = sinon.stub(), ol_context: {is_restricted_user: true}} - ]) - data = JSON.stringify - room_id: @room_id - message: @message - payload: @payload - @WebsocketLoadBalancer._processEditorEvent(@io, "editor-events", data) - - it "should send the message to all (unique) clients in the room", -> - @io.sockets.clients - .calledWith(@room_id) - .should.equal true - @emit1.calledWith(@message, @payload...).should.equal true - @emit2.calledWith(@message, @payload...).should.equal true - @emit3.called.should.equal false # duplicate client should be ignored - @emit4.called.should.equal true # restricted client, but should be called - - describe "with a designated room, and restricted clients, restricted message", -> - beforeEach -> - @io.sockets = - clients: sinon.stub().returns([ - {id: 'client-id-1', emit: @emit1 = sinon.stub(), ol_context: {}} - {id: 'client-id-2', emit: @emit2 = sinon.stub(), ol_context: {}} - {id: 'client-id-1', emit: @emit3 = sinon.stub(), ol_context: {}} # duplicate client - {id: 'client-id-4', emit: @emit4 = sinon.stub(), ol_context: {is_restricted_user: true}} - ]) - data = JSON.stringify - room_id: @room_id - message: @restrictedMessage = 'new-comment' - payload: @payload - @WebsocketLoadBalancer._processEditorEvent(@io, "editor-events", data) - - it "should send the message to all (unique) clients in the room, who are not restricted", -> - @io.sockets.clients - .calledWith(@room_id) - .should.equal true - @emit1.calledWith(@restrictedMessage, @payload...).should.equal true - @emit2.calledWith(@restrictedMessage, @payload...).should.equal true - @emit3.called.should.equal false # duplicate client should be ignored - @emit4.called.should.equal false # restricted client, should not be called - - describe "when emitting to all", -> - beforeEach -> - @io.sockets = - emit: @emit = sinon.stub() - data = JSON.stringify - room_id: "all" - message: @message - payload: @payload - @WebsocketLoadBalancer._processEditorEvent(@io, "editor-events", data) - - it "should send the message to all clients", -> - @emit.calledWith(@message, @payload...).should.equal true diff --git a/services/real-time/test/unit/coffee/helpers/MockClient.coffee b/services/real-time/test/unit/coffee/helpers/MockClient.coffee deleted file mode 100644 index 497928132a..0000000000 --- a/services/real-time/test/unit/coffee/helpers/MockClient.coffee +++ /dev/null @@ -1,13 +0,0 @@ -sinon = require('sinon') - -idCounter = 0 - -module.exports = class MockClient - constructor: () -> - @ol_context = {} - @join = sinon.stub() - @emit = sinon.stub() - @disconnect = sinon.stub() - @id = idCounter++ - @publicId = idCounter++ - disconnect: () -> diff --git a/services/real-time/test/unit/js/AuthorizationManagerTests.js b/services/real-time/test/unit/js/AuthorizationManagerTests.js new file mode 100644 index 0000000000..3093017a39 --- /dev/null +++ b/services/real-time/test/unit/js/AuthorizationManagerTests.js @@ -0,0 +1,320 @@ +/* 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 chai = require('chai') +chai.should() +const { expect } = chai +const sinon = require('sinon') +const SandboxedModule = require('sandboxed-module') +const path = require('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 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') + ) + }) + }) + }) + }) + + return 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') + ) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/ChannelManagerTests.js b/services/real-time/test/unit/js/ChannelManagerTests.js new file mode 100644 index 0000000000..6026f6ab5c --- /dev/null +++ b/services/real-time/test/unit/js/ChannelManagerTests.js @@ -0,0 +1,438 @@ +/* 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 chai = require('chai') +const should = chai.should() +const { expect } = 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: { + 'settings-sharelatex': (this.settings = {}), + 'metrics-sharelatex': (this.metrics = { + inc: sinon.stub(), + summary: sinon.stub() + }), + 'logger-sharelatex': (this.logger = { + log: sinon.stub(), + warn: sinon.stub(), + error: 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('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) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js new file mode 100644 index 0000000000..8e84c41130 --- /dev/null +++ b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js @@ -0,0 +1,409 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 should = require('chai').should() +const SandboxedModule = require('sandboxed-module') +const assert = require('assert') +const path = require('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 () { + this.settings = { + redis: { + realtime: { + key_schema: { + clientsInProject({ project_id }) { + return `clients_in_project:${project_id}` + }, + connectedUser({ project_id, client_id }) { + return `connected_user:${project_id}:${client_id}` + } + } + } + } + } + this.rClient = { + auth() {}, + 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 + } + } + tk.freeze(new Date()) + + this.ConnectedUsersManager = SandboxedModule.require(modulePath, { + requires: { + 'settings-sharelatex': this.settings, + 'logger-sharelatex': { log() {} }, + 'redis-sharelatex': { + 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 () { + return this.rClient.exec.callsArgWith(0) + }) + + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + this.rClient.expire + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 60 * 15 + ) + .should.equal(true) + return done() + } + ) + }) + + return 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) => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'cursorData', + JSON.stringify(this.cursorData) + ) + .should.equal(true) + return done() + } + ) + }) + }) + + describe('markUserAsDisconnected', function () { + beforeEach(function () { + return this.rClient.exec.callsArgWith(0) + }) + + it('should remove the user from the set', function (done) { + return this.ConnectedUsersManager.markUserAsDisconnected( + this.project_id, + this.client_id, + (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) => { + this.rClient.del + .calledWith(`connected_user:${this.project_id}:${this.client_id}`) + .should.equal(true) + return done() + } + ) + }) + + return 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) => { + this.rClient.expire + .calledWith( + `clients_in_project:${this.project_id}`, + 24 * 4 * 60 * 60 + ) + .should.equal(true) + return 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) => { + 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) => { + 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) => { + 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) => { + 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() + } + ) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js b/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js new file mode 100644 index 0000000000..532346f359 --- /dev/null +++ b/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js @@ -0,0 +1,259 @@ +/* eslint-disable + camelcase, + 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') +require('chai').should() +const modulePath = require('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() } + return (this.EditorUpdatesController = SandboxedModule.require(modulePath, { + requires: { + 'logger-sharelatex': (this.logger = { + error: sinon.stub(), + log: sinon.stub(), + warn: sinon.stub() + }), + 'settings-sharelatex': (this.settings = { + redis: { + documentupdater: { + key_schema: { + pendingUpdates({ doc_id }) { + return `PendingUpdates:${doc_id}` + } + } + }, + pubsub: null + } + }), + 'redis-sharelatex': (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() }, + 'metrics-sharelatex': (this.metrics = { inc: 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() + return this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater() + }) + + it('should subscribe to the doc-updater stream', function () { + return this.rclient[0].subscribe + .calledWith('applied-ops') + .should.equal(true) + }) + + it('should register a callback to handle updates', function () { + return this.rclient[0].on.calledWith('message').should.equal(true) + }) + + return it('should subscribe to any additional doc-updater stream', function () { + this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true) + return 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' + ) + }) + + return 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) + ) + }) + + return it('should apply the update', function () { + return this.EditorUpdatesController._applyUpdateFromDocumentUpdater + .calledWith(this.io, this.doc_id, this.message.op) + .should.equal(true) + }) + }) + + return 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) + ) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js new file mode 100644 index 0000000000..dc42b52140 --- /dev/null +++ b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js @@ -0,0 +1,381 @@ +/* eslint-disable + camelcase, + 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 + */ +require('chai').should() +const sinon = require('sinon') +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const modulePath = '../../../app/js/DocumentUpdaterManager' + +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 }) { + return `PendingUpdates:${doc_id}` + } + } + } + }, + maxUpdateSize: 7 * 1024 * 1024 + } + this.rclient = { auth() {} } + + return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, { + requires: { + 'settings-sharelatex': this.settings, + 'logger-sharelatex': (this.logger = { + log: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub() + }), + request: (this.request = {}), + 'redis-sharelatex': { createClient: () => this.rclient }, + 'metrics-sharelatex': (this.Metrics = { + summary: sinon.stub(), + Timer: (Timer = class Timer { + done() {} + }) + }) + }, + globals: { + JSON: (this.JSON = Object.create(JSON)) + } + })) + }) // 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}` + 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) + const err = this.callback.getCall(0).args[0] + err.should.have.property('statusCode', statusCode) + err.should.have.property( + 'message', + 'doc updater could not load requested ops' + ) + this.logger.error.called.should.equal(false) + return this.logger.warn.called.should.equal(true) + }) + }) + ) + + 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) + const err = this.callback.getCall(0).args[0] + err.should.have.property('statusCode', 500) + err.should.have.property( + 'message', + 'doc updater returned a non-success status code: 500' + ) + return this.logger.error.called.should.equal(true) + }) + }) + }) + + 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) + const err = this.callback.getCall(0).args[0] + err.should.have.property('statusCode', 500) + return err.should.have.property( + 'message', + 'document updater returned a failure status code: 500' + ) + }) + }) + }) + + return 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 () { + return this.DocumentUpdaterManager.queueChange( + this.project_id, + this.doc_id, + this.change, + this.callback + ) + }) + + it('should push the change', function () { + return this.rclient.rpush + .calledWith( + `PendingUpdates:${this.doc_id}`, + JSON.stringify(this.change) + ) + .should.equal(true) + }) + + return it('should notify the doc updater of the change via the pending-updates-list queue', function () { + return this.rclient.rpush + .calledWith( + 'pending-updates-list', + `${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.JSON.stringify = () => '["bad bytes! \u0000 <- here"]' + 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) + }) + + 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].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) + }) + }) + + return 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 + ) + }) + + return 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) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DrainManagerTests.js b/services/real-time/test/unit/js/DrainManagerTests.js new file mode 100644 index 0000000000..7ed7f1e06e --- /dev/null +++ b/services/real-time/test/unit/js/DrainManagerTests.js @@ -0,0 +1,132 @@ +/* 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 should = require('chai').should() +const sinon = require('sinon') +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const modulePath = path.join(__dirname, '../../../app/js/DrainManager') + +describe('DrainManager', function () { + beforeEach(function () { + this.DrainManager = SandboxedModule.require(modulePath, { + requires: { + 'logger-sharelatex': (this.logger = { log: sinon.stub() }) + } + }) + 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.log + .calledWith('All clients have been told to reconnectGracefully') + .should.equal(true) + }) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/EventLoggerTests.js b/services/real-time/test/unit/js/EventLoggerTests.js new file mode 100644 index 0000000000..7152f92ce7 --- /dev/null +++ b/services/real-time/test/unit/js/EventLoggerTests.js @@ -0,0 +1,157 @@ +/* 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 + */ +require('chai').should() +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: { + 'logger-sharelatex': (this.logger = { + error: sinon.stub(), + warn: sinon.stub() + }), + 'metrics-sharelatex': (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`, 1) + .should.equal.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`, 1) + .should.equal.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`, + 1 + ).should.equal.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 + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/RoomManagerTests.js b/services/real-time/test/unit/js/RoomManagerTests.js new file mode 100644 index 0000000000..3aee509af5 --- /dev/null +++ b/services/real-time/test/unit/js/RoomManagerTests.js @@ -0,0 +1,419 @@ +/* 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 chai = require('chai') +const { expect } = chai +const should = chai.should() +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: { + 'settings-sharelatex': (this.settings = {}), + 'logger-sharelatex': (this.logger = { + log: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub() + }), + 'metrics-sharelatex': (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 () { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.project_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(124) + this.client.join = sinon.stub() + return this.RoomManager.joinProject(this.client, this.project_id) + }) + + 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 () { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(124) + this.client.join = sinon.stub() + return this.RoomManager.joinDoc(this.client, this.doc_id) + }) + + 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) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/SafeJsonParseTest.js b/services/real-time/test/unit/js/SafeJsonParseTest.js new file mode 100644 index 0000000000..4fb558a6b0 --- /dev/null +++ b/services/real-time/test/unit/js/SafeJsonParseTest.js @@ -0,0 +1,59 @@ +/* eslint-disable + camelcase, + handle-callback-err, + 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 + */ +require('chai').should() +const { expect } = require('chai') +const SandboxedModule = require('sandboxed-module') +const modulePath = '../../../app/js/SafeJsonParse' +const sinon = require('sinon') + +describe('SafeJsonParse', function () { + beforeEach(function () { + return (this.SafeJsonParse = SandboxedModule.require(modulePath, { + requires: { + 'settings-sharelatex': (this.Settings = { + maxUpdateSize: 16 * 1024 + }), + 'logger-sharelatex': (this.logger = { error: sinon.stub() }) + } + })) + }) + + return describe('parse', function () { + it('should parse documents correctly', function (done) { + return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => { + 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 big_blob = Array(16 * 1024).join('A') + const data = `{\"foo\": \"${big_blob}\"}` + this.Settings.maxUpdateSize = 2 * 1024 + return this.SafeJsonParse.parse(data, (error, parsed) => { + this.logger.error.called.should.equal(true) + expect(error).to.exist + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/SessionSocketsTests.js b/services/real-time/test/unit/js/SessionSocketsTests.js new file mode 100644 index 0000000000..f4ae34bf78 --- /dev/null +++ b/services/real-time/test/unit/js/SessionSocketsTests.js @@ -0,0 +1,197 @@ +/* eslint-disable + handle-callback-err, + 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('events') +const { expect } = require('chai') +const SandboxedModule = require('sandboxed-module') +const modulePath = '../../../app/js/SessionSockets' +const sinon = require('sinon') + +describe('SessionSockets', function () { + before(function () { + this.SessionSocketsModule = SandboxedModule.require(modulePath) + 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 () { + before(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() + }) + }) + + return 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 different cookie', function () { + before(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() + }) + }) + + return 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 valid cookie and a failing session lookup', function () { + before(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() + }) + }) + + return 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() + }) + }) + }) + + describe('with a valid cookie and no matching session', function () { + before(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() + }) + }) + + return 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() + }) + }) + }) + + describe('with a valid cookie and a matching session', function () { + before(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() + }) + }) + + return it('should return the session', function (done) { + return this.checkSocket(this.socket, (error, s, session) => { + expect(session).to.deep.equal({ user: { _id: '123' } }) + return done() + }) + }) + }) + + return describe('with a different valid cookie and matching session', function () { + before(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() + }) + }) + + return it('should return the other session', function (done) { + return this.checkSocket(this.socket, (error, s, session) => { + expect(session).to.deep.equal({ user: { _id: 'abc' } }) + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebApiManagerTests.js b/services/real-time/test/unit/js/WebApiManagerTests.js new file mode 100644 index 0000000000..2d792b563b --- /dev/null +++ b/services/real-time/test/unit/js/WebApiManagerTests.js @@ -0,0 +1,162 @@ +/* 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 chai = require('chai') +const should = chai.should() +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 = {}), + 'settings-sharelatex': (this.settings = { + apis: { + web: { + url: 'http://web.example.com', + user: 'username', + pass: 'password' + } + } + }), + 'logger-sharelatex': (this.logger = { + log: sinon.stub(), + error: sinon.stub() + }) + } + })) + }) + + return describe('joinProject', function () { + describe('successfully', function () { + beforeEach(function () { + this.response = { + project: { name: 'Test project' }, + privilegeLevel: 'owner', + isRestrictedUser: 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`, + qs: { + user_id: this.user_id + }, + auth: { + user: this.settings.apis.web.user, + pass: this.settings.apis.web.pass, + sendImmediately: true + }, + json: true, + jar: false, + headers: {} + }) + .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, + this.response.isRestrictedUser + ) + .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: 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', + code: 'TooManyRequests' + }) + ) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebsocketControllerTests.js b/services/real-time/test/unit/js/WebsocketControllerTests.js new file mode 100644 index 0000000000..8d8a39bb74 --- /dev/null +++ b/services/real-time/test/unit/js/WebsocketControllerTests.js @@ -0,0 +1,1494 @@ +/* eslint-disable + camelcase, + no-return-assign, + no-throw-literal, + no-unused-vars, +*/ +// 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 chai = require('chai') +const should = chai.should() +const sinon = require('sinon') +const { expect } = chai +const modulePath = '../../../app/js/WebsocketController.js' +const SandboxedModule = require('sandboxed-module') +const tk = require('timekeeper') + +describe('WebsocketController', function () { + beforeEach(function () { + tk.freeze(new Date()) + this.project_id = 'project-id-123' + this.user = { + _id: (this.user_id = 'user-id-123'), + first_name: 'James', + last_name: 'Allen', + email: 'james@example.com', + signUpDate: new Date('2014-01-01'), + loginCount: 42 + } + this.callback = sinon.stub() + this.client = { + disconnected: false, + id: (this.client_id = 'mock-client-id-123'), + publicId: `other-id-${Math.random()}`, + ol_context: {}, + join: sinon.stub(), + leave: sinon.stub() + } + return (this.WebsocketController = SandboxedModule.require(modulePath, { + requires: { + './WebApiManager': (this.WebApiManager = {}), + './AuthorizationManager': (this.AuthorizationManager = {}), + './DocumentUpdaterManager': (this.DocumentUpdaterManager = {}), + './ConnectedUsersManager': (this.ConnectedUsersManager = {}), + './WebsocketLoadBalancer': (this.WebsocketLoadBalancer = {}), + 'logger-sharelatex': (this.logger = { + log: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub() + }), + 'metrics-sharelatex': (this.metrics = { + inc: sinon.stub(), + set: sinon.stub() + }), + './RoomManager': (this.RoomManager = {}) + } + })) + }) + + afterEach(function () { + return tk.reset() + }) + + describe('joinProject', function () { + describe('when authorised', function () { + beforeEach(function () { + this.client.id = 'mock-client-id' + this.project = { + name: 'Test Project', + owner: { + _id: (this.owner_id = 'mock-owner-id-123') + } + } + this.privilegeLevel = 'owner' + this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4) + this.isRestrictedUser = true + this.WebApiManager.joinProject = sinon + .stub() + .callsArgWith( + 2, + null, + this.project, + this.privilegeLevel, + this.isRestrictedUser + ) + this.RoomManager.joinProject = sinon.stub().callsArg(2) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should load the project from web', function () { + return this.WebApiManager.joinProject + .calledWith(this.project_id, this.user) + .should.equal(true) + }) + + it('should join the project room', function () { + return this.RoomManager.joinProject + .calledWith(this.client, this.project_id) + .should.equal(true) + }) + + it('should set the privilege level on the client', function () { + return this.client.ol_context.privilege_level.should.equal( + this.privilegeLevel + ) + }) + it("should set the user's id on the client", function () { + return this.client.ol_context.user_id.should.equal(this.user._id) + }) + it("should set the user's email on the client", function () { + return this.client.ol_context.email.should.equal(this.user.email) + }) + it("should set the user's first_name on the client", function () { + return this.client.ol_context.first_name.should.equal( + this.user.first_name + ) + }) + it("should set the user's last_name on the client", function () { + return this.client.ol_context.last_name.should.equal( + this.user.last_name + ) + }) + it("should set the user's sign up date on the client", function () { + return this.client.ol_context.signup_date.should.equal( + this.user.signUpDate + ) + }) + it("should set the user's login_count on the client", function () { + return this.client.ol_context.login_count.should.equal( + this.user.loginCount + ) + }) + it('should set the connected time on the client', function () { + return this.client.ol_context.connected_time.should.equal(new Date()) + }) + it('should set the project_id on the client', function () { + return this.client.ol_context.project_id.should.equal(this.project_id) + }) + it('should set the project owner id on the client', function () { + return this.client.ol_context.owner_id.should.equal(this.owner_id) + }) + it('should set the is_restricted_user flag on the client', function () { + return this.client.ol_context.is_restricted_user.should.equal( + this.isRestrictedUser + ) + }) + it('should call the callback with the project, privilegeLevel and protocolVersion', function () { + return this.callback + .calledWith( + null, + this.project, + this.privilegeLevel, + this.WebsocketController.PROTOCOL_VERSION + ) + .should.equal(true) + }) + + it('should mark the user as connected in ConnectedUsersManager', function () { + return this.ConnectedUsersManager.updateUserPosition + .calledWith(this.project_id, this.client.publicId, this.user, null) + .should.equal(true) + }) + + return it('should increment the join-project metric', function () { + return this.metrics.inc + .calledWith('editor.join-project') + .should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.WebApiManager.joinProject = sinon + .stub() + .callsArgWith(2, null, null, null) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should return an error', function () { + return this.callback + .calledWith(sinon.match({ message: 'not authorized' })) + .should.equal(true) + }) + + return it('should not log an error', function () { + return this.logger.error.called.should.equal(false) + }) + }) + + describe('when the subscribe failed', function () { + beforeEach(function () { + this.client.id = 'mock-client-id' + this.project = { + name: 'Test Project', + owner: { + _id: (this.owner_id = 'mock-owner-id-123') + } + } + this.privilegeLevel = 'owner' + this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4) + this.isRestrictedUser = true + this.WebApiManager.joinProject = sinon + .stub() + .callsArgWith( + 2, + null, + this.project, + this.privilegeLevel, + this.isRestrictedUser + ) + this.RoomManager.joinProject = sinon + .stub() + .callsArgWith(2, new Error('subscribe failed')) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + return it('should return an error', function () { + this.callback + .calledWith(sinon.match({ message: 'subscribe failed' })) + .should.equal(true) + return this.callback.args[0][0].message.should.equal('subscribe failed') + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + this.WebApiManager.joinProject = sinon.stub().callsArg(2) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should not call WebApiManager.joinProject', function () { + return expect(this.WebApiManager.joinProject.called).to.equal(false) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-project.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-project.disconnected', 1, { + status: 'immediately' + }) + ).to.equal(true) + }) + }) + + return describe('when the client disconnects while WebApiManager.joinProject is running', function () { + beforeEach(function () { + this.WebApiManager.joinProject = (project, user, cb) => { + this.client.disconnected = true + return cb( + null, + this.project, + this.privilegeLevel, + this.isRestrictedUser + ) + } + + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-project.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-project.disconnected', 1, { + status: 'after-web-api-call' + }) + ).to.equal(true) + }) + }) + }) + + describe('leaveProject', function () { + beforeEach(function () { + this.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon + .stub() + .callsArg(1) + this.ConnectedUsersManager.markUserAsDisconnected = sinon + .stub() + .callsArg(2) + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + this.RoomManager.leaveProjectAndDocs = sinon.stub() + this.clientsInRoom = [] + this.io = { + sockets: { + clients: (room_id) => { + if (room_id !== this.project_id) { + throw 'expected room_id to be project_id' + } + return this.clientsInRoom + } + } + } + this.client.ol_context.project_id = this.project_id + this.client.ol_context.user_id = this.user_id + this.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0 + return tk.reset() + }) // Allow setTimeout to work. + + describe('when the client did not joined a project yet', function () { + beforeEach(function (done) { + this.client.ol_context = {} + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should bail out when calling leaveProject', function () { + this.WebsocketLoadBalancer.emitToRoom.called.should.equal(false) + this.RoomManager.leaveProjectAndDocs.called.should.equal(false) + return this.ConnectedUsersManager.markUserAsDisconnected.called.should.equal( + false + ) + }) + + return it('should not inc any metric', function () { + return this.metrics.inc.called.should.equal(false) + }) + }) + + describe('when the project is empty', function () { + beforeEach(function (done) { + this.clientsInRoom = [] + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should end clientTracking.clientDisconnected to the project room', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientDisconnected', + this.client.publicId + ) + .should.equal(true) + }) + + it('should mark the user as disconnected', function () { + return this.ConnectedUsersManager.markUserAsDisconnected + .calledWith(this.project_id, this.client.publicId) + .should.equal(true) + }) + + it('should flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(this.project_id) + .should.equal(true) + }) + + it('should increment the leave-project metric', function () { + return this.metrics.inc + .calledWith('editor.leave-project') + .should.equal(true) + }) + + return it('should track the disconnection in RoomManager', function () { + return this.RoomManager.leaveProjectAndDocs + .calledWith(this.client) + .should.equal(true) + }) + }) + + describe('when the project is not empty', function () { + beforeEach(function () { + this.clientsInRoom = ['mock-remaining-client'] + return this.WebsocketController.leaveProject(this.io, this.client) + }) + + return it('should not flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete.called.should.equal( + false + ) + }) + }) + + describe('when client has not authenticated', function () { + beforeEach(function (done) { + this.client.ol_context.user_id = null + this.client.ol_context.project_id = null + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should not end clientTracking.clientDisconnected to the project room', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientDisconnected', + this.client.publicId + ) + .should.equal(false) + }) + + it('should not mark the user as disconnected', function () { + return this.ConnectedUsersManager.markUserAsDisconnected + .calledWith(this.project_id, this.client.publicId) + .should.equal(false) + }) + + it('should not flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(this.project_id) + .should.equal(false) + }) + + return it('should not increment the leave-project metric', function () { + return this.metrics.inc + .calledWith('editor.leave-project') + .should.equal(false) + }) + }) + + return describe('when client has not joined a project', function () { + beforeEach(function (done) { + this.client.ol_context.user_id = this.user_id + this.client.ol_context.project_id = null + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should not end clientTracking.clientDisconnected to the project room', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientDisconnected', + this.client.publicId + ) + .should.equal(false) + }) + + it('should not mark the user as disconnected', function () { + return this.ConnectedUsersManager.markUserAsDisconnected + .calledWith(this.project_id, this.client.publicId) + .should.equal(false) + }) + + it('should not flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(this.project_id) + .should.equal(false) + }) + + return it('should not increment the leave-project metric', function () { + return this.metrics.inc + .calledWith('editor.leave-project') + .should.equal(false) + }) + }) + }) + + describe('joinDoc', function () { + beforeEach(function () { + this.doc_id = 'doc-id-123' + this.doc_lines = ['doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'ops'] + this.ranges = { mock: 'ranges' } + this.options = {} + + this.client.ol_context.project_id = this.project_id + this.client.ol_context.is_restricted_user = false + this.AuthorizationManager.addAccessToDoc = sinon.stub() + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + this.DocumentUpdaterManager.getDocument = sinon + .stub() + .callsArgWith( + 3, + null, + this.doc_lines, + this.version, + this.ranges, + this.ops + ) + return (this.RoomManager.joinDoc = sinon.stub().callsArg(2)) + }) + + describe('works', function () { + beforeEach(function () { + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should check that the client is authorized to view the project', function () { + return this.AuthorizationManager.assertClientCanViewProject + .calledWith(this.client) + .should.equal(true) + }) + + it('should get the document from the DocumentUpdaterManager with fromVersion', function () { + return this.DocumentUpdaterManager.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should add permissions for the client to access the doc', function () { + return this.AuthorizationManager.addAccessToDoc + .calledWith(this.client, this.doc_id) + .should.equal(true) + }) + + it('should join the client to room for the doc_id', function () { + return this.RoomManager.joinDoc + .calledWith(this.client, this.doc_id) + .should.equal(true) + }) + + it('should call the callback with the lines, version, ranges and ops', function () { + return this.callback + .calledWith(null, this.doc_lines, this.version, this.ops, this.ranges) + .should.equal(true) + }) + + return it('should increment the join-doc metric', function () { + return this.metrics.inc.calledWith('editor.join-doc').should.equal(true) + }) + }) + + describe('with a fromVersion', function () { + beforeEach(function () { + this.fromVersion = 40 + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + this.fromVersion, + this.options, + this.callback + ) + }) + + return it('should get the document from the DocumentUpdaterManager with fromVersion', function () { + return this.DocumentUpdaterManager.getDocument + .calledWith(this.project_id, this.doc_id, this.fromVersion) + .should.equal(true) + }) + }) + + describe('with doclines that need escaping', function () { + beforeEach(function () { + this.doc_lines.push(['räksmörgås']) + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + return it('should call the callback with the escaped lines', function () { + const escaped_lines = this.callback.args[0][1] + const escaped_word = escaped_lines.pop() + escaped_word.should.equal('räksmörgÃ¥s') + // Check that unescaping works + return decodeURIComponent(escape(escaped_word)).should.equal( + 'räksmörgås' + ) + }) + }) + + describe('with comments that need encoding', function () { + beforeEach(function () { + this.ranges.comments = [{ op: { c: 'räksmörgås' } }] + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + { encodeRanges: true }, + this.callback + ) + }) + + return it('should call the callback with the encoded comment', function () { + const encoded_comments = this.callback.args[0][4] + const encoded_comment = encoded_comments.comments.pop() + const encoded_comment_text = encoded_comment.op.c + return encoded_comment_text.should.equal('räksmörgÃ¥s') + }) + }) + + describe('with changes that need encoding', function () { + it('should call the callback with the encoded insert change', function () { + this.ranges.changes = [{ op: { i: 'räksmörgås' } }] + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + { encodeRanges: true }, + this.callback + ) + + const encoded_changes = this.callback.args[0][4] + const encoded_change = encoded_changes.changes.pop() + const encoded_change_text = encoded_change.op.i + return encoded_change_text.should.equal('räksmörgÃ¥s') + }) + + return it('should call the callback with the encoded delete change', function () { + this.ranges.changes = [{ op: { d: 'räksmörgås' } }] + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + { encodeRanges: true }, + this.callback + ) + + const encoded_changes = this.callback.args[0][4] + const encoded_change = encoded_changes.changes.pop() + const encoded_change_text = encoded_change.op.d + return encoded_change_text.should.equal('räksmörgÃ¥s') + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, (this.err = new Error('not authorized'))) + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with an error', function () { + return this.callback + .calledWith(sinon.match({ message: 'not authorized' })) + .should.equal(true) + }) + + return it('should not call the DocumentUpdaterManager', function () { + return this.DocumentUpdaterManager.getDocument.called.should.equal( + false + ) + }) + }) + + describe('with a restricted client', function () { + beforeEach(function () { + this.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }] + this.client.ol_context.is_restricted_user = true + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + return it('should overwrite ranges.comments with an empty list', function () { + const ranges = this.callback.args[0][4] + return expect(ranges.comments).to.deep.equal([]) + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'immediately' + }) + ).to.equal(true) + }) + + return it('should not get the document', function () { + return expect(this.DocumentUpdaterManager.getDocument.called).to.equal( + false + ) + }) + }) + + describe('when the client disconnects while RoomManager.joinDoc is running', function () { + beforeEach(function () { + this.RoomManager.joinDoc = (client, doc_id, cb) => { + this.client.disconnected = true + return cb() + } + + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-joining-room' + }) + ).to.equal(true) + }) + + return it('should not get the document', function () { + return expect(this.DocumentUpdaterManager.getDocument.called).to.equal( + false + ) + }) + }) + + return describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () { + beforeEach(function () { + this.DocumentUpdaterManager.getDocument = ( + project_id, + doc_id, + fromVersion, + callback + ) => { + this.client.disconnected = true + return callback( + null, + this.doc_lines, + this.version, + this.ranges, + this.ops + ) + } + + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-doc.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-doc-updater-call' + }) + ).to.equal(true) + }) + }) + }) + + describe('leaveDoc', function () { + beforeEach(function () { + this.doc_id = 'doc-id-123' + this.client.ol_context.project_id = this.project_id + this.RoomManager.leaveDoc = sinon.stub() + return this.WebsocketController.leaveDoc( + this.client, + this.doc_id, + this.callback + ) + }) + + it('should remove the client from the doc_id room', function () { + return this.RoomManager.leaveDoc + .calledWith(this.client, this.doc_id) + .should.equal(true) + }) + + it('should call the callback', function () { + return this.callback.called.should.equal(true) + }) + + return it('should increment the leave-doc metric', function () { + return this.metrics.inc.calledWith('editor.leave-doc').should.equal(true) + }) + }) + + describe('getConnectedUsers', function () { + beforeEach(function () { + this.client.ol_context.project_id = this.project_id + this.users = ['mock', 'users'] + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + return (this.ConnectedUsersManager.getConnectedUsers = sinon + .stub() + .callsArgWith(1, null, this.users)) + }) + + describe('when authorized', function () { + beforeEach(function (done) { + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + return this.WebsocketController.getConnectedUsers( + this.client, + (...args) => { + this.callback(...Array.from(args || [])) + return done() + } + ) + }) + + it('should check that the client is authorized to view the project', function () { + return this.AuthorizationManager.assertClientCanViewProject + .calledWith(this.client) + .should.equal(true) + }) + + it('should broadcast a request to update the client list', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith(this.project_id, 'clientTracking.refresh') + .should.equal(true) + }) + + it('should get the connected users for the project', function () { + return this.ConnectedUsersManager.getConnectedUsers + .calledWith(this.project_id) + .should.equal(true) + }) + + it('should return the users', function () { + return this.callback.calledWith(null, this.users).should.equal(true) + }) + + return it('should increment the get-connected-users metric', function () { + return this.metrics.inc + .calledWith('editor.get-connected-users') + .should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, (this.err = new Error('not authorized'))) + return this.WebsocketController.getConnectedUsers( + this.client, + this.callback + ) + }) + + it('should not get the connected users for the project', function () { + return this.ConnectedUsersManager.getConnectedUsers.called.should.equal( + false + ) + }) + + return it('should return an error', function () { + return this.callback.calledWith(this.err).should.equal(true) + }) + }) + + describe('when restricted user', function () { + beforeEach(function () { + this.client.ol_context.is_restricted_user = true + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + return this.WebsocketController.getConnectedUsers( + this.client, + this.callback + ) + }) + + it('should return an empty array of users', function () { + return this.callback.calledWith(null, []).should.equal(true) + }) + + return it('should not get the connected users for the project', function () { + return this.ConnectedUsersManager.getConnectedUsers.called.should.equal( + false + ) + }) + }) + + return describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + this.AuthorizationManager.assertClientCanViewProject = sinon.stub() + return this.WebsocketController.getConnectedUsers( + this.client, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should not check permissions', function () { + return expect( + this.AuthorizationManager.assertClientCanViewProject.called + ).to.equal(false) + }) + }) + }) + + describe('updateClientPosition', function () { + beforeEach(function () { + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + this.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgWith(4) + this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon + .stub() + .callsArgWith(2, null) + return (this.update = { + doc_id: (this.doc_id = 'doc-id-123'), + row: (this.row = 42), + column: (this.column = 37) + }) + }) + + describe('with a logged in user', function () { + beforeEach(function () { + this.client.ol_context = { + project_id: this.project_id, + first_name: (this.first_name = 'Douglas'), + last_name: (this.last_name = 'Adams'), + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123') + } + this.WebsocketController.updateClientPosition(this.client, this.update) + + return (this.populatedCursorData = { + doc_id: this.doc_id, + id: this.client.publicId, + name: `${this.first_name} ${this.last_name}`, + row: this.row, + column: this.column, + email: this.email, + user_id: this.user_id + }) + }) + + it("should send the update to the project room with the user's name", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientUpdated', + this.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition + .calledWith( + this.project_id, + this.client.publicId, + { + _id: this.user_id, + email: this.email, + first_name: this.first_name, + last_name: this.last_name + }, + { + row: this.row, + column: this.column, + doc_id: this.doc_id + } + ) + .should.equal(true) + return done() + }) + + return it('should increment the update-client-position metric at 0.1 frequency', function () { + return this.metrics.inc + .calledWith('editor.update-client-position', 0.1) + .should.equal(true) + }) + }) + + describe('with a logged in user who has no last_name set', function () { + beforeEach(function () { + this.client.ol_context = { + project_id: this.project_id, + first_name: (this.first_name = 'Douglas'), + last_name: undefined, + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123') + } + this.WebsocketController.updateClientPosition(this.client, this.update) + + return (this.populatedCursorData = { + doc_id: this.doc_id, + id: this.client.publicId, + name: `${this.first_name}`, + row: this.row, + column: this.column, + email: this.email, + user_id: this.user_id + }) + }) + + it("should send the update to the project room with the user's name", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientUpdated', + this.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition + .calledWith( + this.project_id, + this.client.publicId, + { + _id: this.user_id, + email: this.email, + first_name: this.first_name, + last_name: undefined + }, + { + row: this.row, + column: this.column, + doc_id: this.doc_id + } + ) + .should.equal(true) + return done() + }) + + return it('should increment the update-client-position metric at 0.1 frequency', function () { + return this.metrics.inc + .calledWith('editor.update-client-position', 0.1) + .should.equal(true) + }) + }) + + describe('with a logged in user who has no first_name set', function () { + beforeEach(function () { + this.client.ol_context = { + project_id: this.project_id, + first_name: undefined, + last_name: (this.last_name = 'Adams'), + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123') + } + this.WebsocketController.updateClientPosition(this.client, this.update) + + return (this.populatedCursorData = { + doc_id: this.doc_id, + id: this.client.publicId, + name: `${this.last_name}`, + row: this.row, + column: this.column, + email: this.email, + user_id: this.user_id + }) + }) + + it("should send the update to the project room with the user's name", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientUpdated', + this.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition + .calledWith( + this.project_id, + this.client.publicId, + { + _id: this.user_id, + email: this.email, + first_name: undefined, + last_name: this.last_name + }, + { + row: this.row, + column: this.column, + doc_id: this.doc_id + } + ) + .should.equal(true) + return done() + }) + + return it('should increment the update-client-position metric at 0.1 frequency', function () { + return this.metrics.inc + .calledWith('editor.update-client-position', 0.1) + .should.equal(true) + }) + }) + describe('with a logged in user who has no names set', function () { + beforeEach(function () { + this.client.ol_context = { + project_id: this.project_id, + first_name: undefined, + last_name: undefined, + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123') + } + return this.WebsocketController.updateClientPosition( + this.client, + this.update + ) + }) + + return it('should send the update to the project name with no name', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith(this.project_id, 'clientTracking.clientUpdated', { + doc_id: this.doc_id, + id: this.client.publicId, + user_id: this.user_id, + name: '', + row: this.row, + column: this.column, + email: this.email + }) + .should.equal(true) + }) + }) + + describe('with an anonymous user', function () { + beforeEach(function () { + this.client.ol_context = { + project_id: this.project_id + } + return this.WebsocketController.updateClientPosition( + this.client, + this.update + ) + }) + + it('should send the update to the project room with no name', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith(this.project_id, 'clientTracking.clientUpdated', { + doc_id: this.doc_id, + id: this.client.publicId, + name: '', + row: this.row, + column: this.column + }) + .should.equal(true) + }) + + return it('should not send cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition.called.should.equal(false) + return done() + }) + }) + + return describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub() + return this.WebsocketController.updateClientPosition( + this.client, + this.update, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should not check permissions', function () { + return expect( + this.AuthorizationManager.assertClientCanViewProjectAndDoc.called + ).to.equal(false) + }) + }) + }) + + describe('applyOtUpdate', function () { + beforeEach(function () { + this.update = { op: { p: 12, t: 'foo' } } + this.client.ol_context.user_id = this.user_id + this.client.ol_context.project_id = this.project_id + this.WebsocketController._assertClientCanApplyUpdate = sinon + .stub() + .yields() + return (this.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArg(3)) + }) + + describe('succesfully', function () { + beforeEach(function () { + return this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + }) + + it('should set the source of the update to the client id', function () { + return this.update.meta.source.should.equal(this.client.publicId) + }) + + it('should set the user_id of the update to the user id', function () { + return this.update.meta.user_id.should.equal(this.user_id) + }) + + it('should queue the update', function () { + return this.DocumentUpdaterManager.queueChange + .calledWith(this.project_id, this.doc_id, this.update) + .should.equal(true) + }) + + it('should call the callback', function () { + return this.callback.called.should.equal(true) + }) + + return it('should increment the doc updates', function () { + return this.metrics.inc + .calledWith('editor.doc-update') + .should.equal(true) + }) + }) + + describe('unsuccessfully', function () { + beforeEach(function () { + this.client.disconnect = sinon.stub() + this.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArgWith(3, (this.error = new Error('Something went wrong'))) + return this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + }) + + it('should disconnect the client', function () { + return this.client.disconnect.called.should.equal(true) + }) + + it('should log an error', function () { + return this.logger.error.called.should.equal(true) + }) + + return it('should call the callback with the error', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.client.disconnect = sinon.stub() + this.WebsocketController._assertClientCanApplyUpdate = sinon + .stub() + .yields((this.error = new Error('not authorized'))) + return this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + }) + + // This happens in a setTimeout to allow the client a chance to receive the error first. + // I'm not sure how to unit test, but it is acceptance tested. + // it "should disconnect the client", -> + // @client.disconnect.called.should.equal true + + it('should log a warning', function () { + return this.logger.warn.called.should.equal(true) + }) + + return it('should call the callback with the error', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + return describe('update_too_large', function () { + beforeEach(function (done) { + this.client.disconnect = sinon.stub() + this.client.emit = sinon.stub() + this.client.ol_context.user_id = this.user_id + this.client.ol_context.project_id = this.project_id + const error = new Error('update is too large') + error.updateSize = 7372835 + this.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArgWith(3, error) + this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + return setTimeout(() => done(), 1) + }) + + it('should call the callback with no error', function () { + this.callback.called.should.equal(true) + return this.callback.args[0].should.deep.equal([]) + }) + + it('should log a warning with the size and context', function () { + this.logger.warn.called.should.equal(true) + return this.logger.warn.args[0].should.deep.equal([ + { + user_id: this.user_id, + project_id: this.project_id, + doc_id: this.doc_id, + updateSize: 7372835 + }, + 'update is too large' + ]) + }) + + describe('after 100ms', function () { + beforeEach(function (done) { + return setTimeout(done, 100) + }) + + it('should send an otUpdateError the client', function () { + return this.client.emit.calledWith('otUpdateError').should.equal(true) + }) + + return it('should disconnect the client', function () { + return this.client.disconnect.called.should.equal(true) + }) + }) + + return describe('when the client disconnects during the next 100ms', function () { + beforeEach(function (done) { + this.client.disconnected = true + return setTimeout(done, 100) + }) + + it('should not send an otUpdateError the client', function () { + return this.client.emit + .calledWith('otUpdateError') + .should.equal(false) + }) + + it('should not disconnect the client', function () { + return this.client.disconnect.called.should.equal(false) + }) + + return it('should increment the editor.doc-update.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.doc-update.disconnected', 1, { + status: 'at-otUpdateError' + }) + ).to.equal(true) + }) + }) + }) + }) + + return describe('_assertClientCanApplyUpdate', function () { + beforeEach(function () { + this.edit_update = { + op: [ + { i: 'foo', p: 42 }, + { c: 'bar', p: 132 } + ] + } // comments may still be in an edit op + this.comment_update = { op: [{ c: 'bar', p: 132 }] } + this.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub() + return (this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub()) + }) + + describe('with a read-write client', function () { + return it('should return successfully', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.edit_update, + (error) => { + expect(error).to.be.null + return done() + } + ) + }) + }) + + describe('with a read-only client and an edit op', function () { + return it('should return an error', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.edit_update, + (error) => { + expect(error.message).to.equal('not authorized') + return done() + } + ) + }) + }) + + describe('with a read-only client and a comment op', function () { + return it('should return successfully', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.comment_update, + (error) => { + expect(error).to.be.null + return done() + } + ) + }) + }) + + return describe('with a totally unauthorized client', function () { + return it('should return an error', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error('not authorized') + ) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.comment_update, + (error) => { + expect(error.message).to.equal('not authorized') + return done() + } + ) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js b/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js new file mode 100644 index 0000000000..355e635353 --- /dev/null +++ b/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js @@ -0,0 +1,309 @@ +/* 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') +require('chai').should() +const modulePath = require('path').join( + __dirname, + '../../../app/js/WebsocketLoadBalancer' +) + +describe('WebsocketLoadBalancer', function () { + beforeEach(function () { + this.rclient = {} + this.RoomEvents = { on: sinon.stub() } + this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, { + requires: { + './RedisClientManager': { + createClientList: () => [] + }, + 'logger-sharelatex': (this.logger = { + log: sinon.stub(), + error: sinon.stub() + }), + './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() + }) + } + }) + this.io = {} + this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }] + this.WebsocketLoadBalancer.rclientSubList = [ + { + subscribe: sinon.stub(), + on: sinon.stub() + } + ] + + this.room_id = 'room-id' + this.message = 'otUpdateApplied' + return (this.payload = ['argument one', 42]) + }) + + describe('emitToRoom', function () { + beforeEach(function () { + return this.WebsocketLoadBalancer.emitToRoom( + this.room_id, + this.message, + ...Array.from(this.payload) + ) + }) + + return it('should publish the message to redis', function () { + return this.ChannelManager.publish + .calledWith( + this.WebsocketLoadBalancer.rclientPubList[0], + 'editor-events', + this.room_id, + JSON.stringify({ + room_id: this.room_id, + message: this.message, + payload: this.payload + }) + ) + .should.equal(true) + }) + }) + + describe('emitToAll', function () { + beforeEach(function () { + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + return this.WebsocketLoadBalancer.emitToAll( + this.message, + ...Array.from(this.payload) + ) + }) + + return it("should emit to the room 'all'", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith('all', this.message, ...Array.from(this.payload)) + .should.equal(true) + }) + }) + + describe('listenForEditorEvents', function () { + beforeEach(function () { + this.WebsocketLoadBalancer._processEditorEvent = sinon.stub() + return this.WebsocketLoadBalancer.listenForEditorEvents() + }) + + it('should subscribe to the editor-events channel', function () { + return this.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 + .calledWith('message', sinon.match.func) + .should.equal(true) + }) + }) + + return describe('_processEditorEvent', function () { + describe('with bad JSON', function () { + beforeEach(function () { + this.isRestrictedUser = false + this.SafeJsonParse.parse = sinon + .stub() + .callsArgWith(1, new Error('oops')) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + 'blah' + ) + }) + + return it('should log an error', function () { + return this.logger.error.called.should.equal(true) + }) + }) + + describe('with a designated room', function () { + beforeEach(function () { + this.io.sockets = { + clients: sinon.stub().returns([ + { + id: 'client-id-1', + emit: (this.emit1 = sinon.stub()), + ol_context: {} + }, + { + id: 'client-id-2', + emit: (this.emit2 = sinon.stub()), + ol_context: {} + }, + { + id: 'client-id-1', + emit: (this.emit3 = sinon.stub()), + ol_context: {} + } // duplicate client + ]) + } + const data = JSON.stringify({ + room_id: this.room_id, + message: this.message, + payload: this.payload + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.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)) + .should.equal(true) + this.emit2 + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + return this.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 = { + clients: sinon.stub().returns([ + { + id: 'client-id-1', + emit: (this.emit1 = sinon.stub()), + ol_context: {} + }, + { + id: 'client-id-2', + emit: (this.emit2 = sinon.stub()), + ol_context: {} + }, + { + id: 'client-id-1', + emit: (this.emit3 = sinon.stub()), + ol_context: {} + }, // duplicate client + { + id: 'client-id-4', + emit: (this.emit4 = sinon.stub()), + ol_context: { is_restricted_user: true } + } + ]) + } + const data = JSON.stringify({ + room_id: this.room_id, + message: this.message, + payload: this.payload + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.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)) + .should.equal(true) + this.emit2 + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + this.emit3.called.should.equal(false) // duplicate client should be ignored + return this.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 = { + clients: sinon.stub().returns([ + { + id: 'client-id-1', + emit: (this.emit1 = sinon.stub()), + ol_context: {} + }, + { + id: 'client-id-2', + emit: (this.emit2 = sinon.stub()), + ol_context: {} + }, + { + id: 'client-id-1', + emit: (this.emit3 = sinon.stub()), + ol_context: {} + }, // duplicate client + { + id: 'client-id-4', + emit: (this.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 + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.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)) + .should.equal(true) + this.emit2 + .calledWith(this.restrictedMessage, ...Array.from(this.payload)) + .should.equal(true) + this.emit3.called.should.equal(false) // duplicate client should be ignored + return this.emit4.called.should.equal(false) + }) + }) // restricted client, should not be called + + return describe('when emitting to all', function () { + beforeEach(function () { + this.io.sockets = { emit: (this.emit = sinon.stub()) } + const data = JSON.stringify({ + room_id: 'all', + message: this.message, + payload: this.payload + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + data + ) + }) + + return it('should send the message to all clients', function () { + return this.emit + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/helpers/MockClient.js b/services/real-time/test/unit/js/helpers/MockClient.js new file mode 100644 index 0000000000..fb90a0f7e9 --- /dev/null +++ b/services/real-time/test/unit/js/helpers/MockClient.js @@ -0,0 +1,22 @@ +/* eslint-disable + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +let MockClient +const sinon = require('sinon') + +let idCounter = 0 + +module.exports = MockClient = class MockClient { + constructor() { + this.ol_context = {} + this.join = sinon.stub() + this.emit = sinon.stub() + this.disconnect = sinon.stub() + this.id = idCounter++ + this.publicId = idCounter++ + } + + disconnect() {} +}