diff --git a/package-lock.json b/package-lock.json index acac4f1602..6f1a669f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -613,36 +613,6 @@ "typescript": "^5.0.4" } }, - "libraries/promise-utils/node_modules/chai": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", - "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", - "dev": true, - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" - }, - "engines": { - "node": ">=4" - } - }, - "libraries/promise-utils/node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, "libraries/promise-utils/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -11596,6 +11566,23 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", @@ -25684,33 +25671,35 @@ } }, "node_modules/chai": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.5" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" } }, "node_modules/chai-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.2.tgz", + "integrity": "sha512-aBDHZxRzYnUYuIAIPBH2s511DjlKPzXNlXSGFC8CwmroWQLfrW0LtE1nK3MAwwNhJPa9raEjNCmRoFpG0Hurdw==", "dev": true, + "license": "WTFPL", "dependencies": { "check-error": "^1.0.2" }, "peerDependencies": { - "chai": ">= 2.1.2 < 5" + "chai": ">= 2.1.2 < 6" } }, "node_modules/chai-exclude": { @@ -25836,6 +25825,16 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/chai/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -28188,15 +28187,16 @@ "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug==" }, "node_modules/deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", "dev": true, + "license": "MIT", "dependencies": { "type-detect": "^4.0.0" }, "engines": { - "node": ">=0.12" + "node": ">=6" } }, "node_modules/deep-equal": { @@ -57962,31 +57962,752 @@ "mocha": "^11.1.0", "mocha-junit-reporter": "^2.2.1", "mocha-multi-reporters": "^1.5.1", - "sandboxed-module": "~0.3.0", "sinon": "^9.2.4", - "sinon-chai": "^3.7.0", + "sinon-chai": "4.0.1", "timekeeper": "0.0.4", "typescript": "^5.0.4", - "uid-safe": "^2.1.5" + "uid-safe": "^2.1.5", + "vitest": "^4.0.0" } }, - "services/real-time/node_modules/sandboxed-module": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/sandboxed-module/-/sandboxed-module-0.3.0.tgz", - "integrity": "sha1-8fvvvYCaT2kHO9B8rm/H2y6vX2o=", - "dev": true, - "dependencies": { - "require-like": "0.1.2", - "stack-trace": "0.0.6" - } - }, - "services/real-time/node_modules/stack-trace": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.6.tgz", - "integrity": "sha1-HnGb1qJin/CcGJ4Xqe+QKpT8XbA=", + "services/real-time/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "*" + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "services/real-time/node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "services/real-time/node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/real-time/node_modules/@vitest/expect/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "services/real-time/node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/real-time/node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/real-time/node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/real-time/node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/real-time/node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "services/real-time/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "services/real-time/node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/chai-as-promised": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.2.tgz", + "integrity": "sha512-1GadL+sEJVLzDjcawPM4kjfnL+p/9vrxiEUonowKOAzvVg0PixJUdtuDzdkDeQhK3zfOE76GqGkZIQ7/Adcrqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "check-error": "^2.1.1" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 7" + } + }, + "services/real-time/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "services/real-time/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "services/real-time/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "services/real-time/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "services/real-time/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "services/real-time/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "services/real-time/node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "services/real-time/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "services/real-time/node_modules/sinon-chai": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-4.0.1.tgz", + "integrity": "sha512-xMKEEV3cYHC1G+boyr7QEqi80gHznYsxVdC9CdjP5JnCWz/jPGuXQzJz3PtBcb0CcHAxar15Y5sjLBoAs6a0yA==", + "dev": true, + "license": "(BSD-2-Clause OR WTFPL)", + "peerDependencies": { + "chai": "^5.0.0 || ^6.0.0", + "sinon": ">=4.0.0" } }, "services/real-time/node_modules/timekeeper": { @@ -57995,6 +58716,197 @@ "integrity": "sha1-kNt58X2Ni1NiFUOJSSuXJ2LP0nY=", "dev": true }, + "services/real-time/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "services/real-time/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "services/real-time/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "services/real-time/node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "services/real-time/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "services/references": { "name": "@overleaf/references", "dependencies": { diff --git a/services/real-time/app.js b/services/real-time/app.js index a66833d9d9..d04a2c7aa6 100644 --- a/services/real-time/app.js +++ b/services/real-time/app.js @@ -1,30 +1,34 @@ // Metrics must be initialized before importing anything else -require('@overleaf/metrics/initialize') +import '@overleaf/metrics/initialize.js' -const Metrics = require('@overleaf/metrics') -const Settings = require('@overleaf/settings') -const async = require('async') +import Metrics from '@overleaf/metrics' +import Settings from '@overleaf/settings' +import async from 'async' +import logger from '@overleaf/logger' +import express from 'express' +import session from 'express-session' +import redis from '@overleaf/redis-wrapper' +import ConnectRedis from 'connect-redis' +import SessionSockets from './app/js/SessionSockets.js' +import CookieParser from 'cookie-parser' +import DrainManager from './app/js/DrainManager.js' +import HealthCheckManager from './app/js/HealthCheckManager.js' +import DeploymentManager from './app/js/DeploymentManager.js' +import Path from 'node:path' +import socketIO from 'socket.io' +import socketIOClient from 'socket.io-client' +import http from 'node:http' +import Router from './app/js/Router.js' +import WebsocketLoadBalancer from './app/js/WebsocketLoadBalancer.js' +import DocumentUpdaterController from './app/js/DocumentUpdaterController.js' -const logger = require('@overleaf/logger') logger.initialize('real-time') Metrics.event_loop.monitor(logger) Metrics.open_sockets.monitor() -const express = require('express') -const session = require('express-session') -const redis = require('@overleaf/redis-wrapper') - const sessionRedisClient = redis.createClient(Settings.redis.websessions) -const RedisStore = require('connect-redis')(session) -const SessionSockets = require('./app/js/SessionSockets') -const CookieParser = require('cookie-parser') - -const DrainManager = require('./app/js/DrainManager') -const HealthCheckManager = require('./app/js/HealthCheckManager') -const DeploymentManager = require('./app/js/DeploymentManager') - -const Path = require('node:path') +const RedisStore = ConnectRedis(session) // NOTE: debug is invoked for every blob that is put on the wire const socketIoLogger = { @@ -45,9 +49,9 @@ DeploymentManager.initialise() // Set up socket.io server const app = express() -const server = require('node:http').createServer(app) +const server = http.createServer(app) server.keepAliveTimeout = Settings.keepAliveTimeoutMs -const io = require('socket.io').listen(server, { +const io = socketIO.listen(server, { logger: socketIoLogger, }) @@ -127,7 +131,7 @@ io.configure(function () { // The express sendFile method correctly handles conditional // requests using the last-modified time and etag (which is // a combination of mtime and size) -const socketIOClientFolder = require('socket.io-client').dist +const socketIOClientFolder = socketIOClient.dist app.get('/socket.io/socket.io.js', function (req, res) { res.sendFile(Path.join(socketIOClientFolder, 'socket.io.min.js')) }) @@ -156,9 +160,7 @@ app.get('/debug/events', function (req, res) { res.send(`debug mode will log next ${Settings.debugEvents} events`) }) -const rclient = require('@overleaf/redis-wrapper').createClient( - Settings.redis.realtime -) +const rclient = redis.createClient(Settings.redis.realtime) function healthCheck(req, res) { rclient.healthCheck(function (error) { @@ -190,13 +192,10 @@ app.get('/health_check/redis', healthCheck) // log http requests for routes defined from this point onwards app.use(Metrics.http.monitor(logger)) -const Router = require('./app/js/Router') Router.configure(app, io, sessionSockets) -const WebsocketLoadBalancer = require('./app/js/WebsocketLoadBalancer') WebsocketLoadBalancer.listenForEditorEvents(io) -const DocumentUpdaterController = require('./app/js/DocumentUpdaterController') DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io) const { port } = Settings.internal.realTime @@ -352,3 +351,5 @@ if (Settings.continualPubsubTraffic) { runPubSubTraffic() } + +export default app diff --git a/services/real-time/app/js/AuthorizationManager.js b/services/real-time/app/js/AuthorizationManager.js index b8633ad4d0..fc151c8224 100644 --- a/services/real-time/app/js/AuthorizationManager.js +++ b/services/real-time/app/js/AuthorizationManager.js @@ -1,7 +1,10 @@ -const { NotAuthorizedError } = require('./Errors') +import Errors from './Errors.js' + +const { NotAuthorizedError } = Errors let AuthorizationManager -module.exports = AuthorizationManager = { + +export default AuthorizationManager = { assertClientCanViewProject(client, callback) { AuthorizationManager._assertClientHasPrivilegeLevel( client, diff --git a/services/real-time/app/js/ChannelManager.js b/services/real-time/app/js/ChannelManager.js index 42b61722af..2df9de2b46 100644 --- a/services/real-time/app/js/ChannelManager.js +++ b/services/real-time/app/js/ChannelManager.js @@ -1,7 +1,7 @@ -const logger = require('@overleaf/logger') -const metrics = require('@overleaf/metrics') -const settings = require('@overleaf/settings') -const OError = require('@overleaf/o-error') +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import settings from '@overleaf/settings' +import OError from '@overleaf/o-error' const ClientMap = new Map() // for each redis client, store a Map of subscribed channels (channelname -> subscribe promise) @@ -9,7 +9,7 @@ const ClientMap = new Map() // for each redis client, store a Map of subscribed // that we never subscribe to a channel multiple times. The socket.io side is // handled by RoomManager. -module.exports = { +export default { getClientMapEntry(rclient) { // return the per-client channel map if it exists, otherwise create and // return an empty map for the client. diff --git a/services/real-time/app/js/ConnectedUsersManager.js b/services/real-time/app/js/ConnectedUsersManager.js index 4ce3dcdcad..5c4983b6c8 100644 --- a/services/real-time/app/js/ConnectedUsersManager.js +++ b/services/real-time/app/js/ConnectedUsersManager.js @@ -1,9 +1,9 @@ -const async = require('async') -const Settings = require('@overleaf/settings') -const logger = require('@overleaf/logger') -const redis = require('@overleaf/redis-wrapper') -const OError = require('@overleaf/o-error') -const Metrics = require('@overleaf/metrics') +import async from 'async' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import redis from '@overleaf/redis-wrapper' +import OError from '@overleaf/o-error' +import Metrics from '@overleaf/metrics' const rclient = redis.createClient(Settings.redis.realtime) const Keys = Settings.redis.realtime.key_schema @@ -28,7 +28,7 @@ function recordProjectNotEmptySinceMetric(res, status) { Metrics.histogram('project_not_empty_since', diff, BUCKETS, { status }) } -module.exports = { +export default { countConnectedClients(projectId, callback) { rclient.scard(Keys.clientsInProject({ project_id: projectId }), callback) }, diff --git a/services/real-time/app/js/DeploymentManager.js b/services/real-time/app/js/DeploymentManager.js index 58c1618676..def1d6fcb3 100644 --- a/services/real-time/app/js/DeploymentManager.js +++ b/services/real-time/app/js/DeploymentManager.js @@ -1,6 +1,6 @@ -const logger = require('@overleaf/logger') -const settings = require('@overleaf/settings') -const fs = require('node:fs') +import logger from '@overleaf/logger' +import settings from '@overleaf/settings' +import fs from 'node:fs' // Monitor a status file (e.g. /etc/real_time_status) periodically and close the // service if the file contents don't contain the matching deployment colour. @@ -45,7 +45,7 @@ function checkStatusFileSync() { } } -module.exports = { +export default { initialise() { if (statusFile && deploymentColour) { logger.info( diff --git a/services/real-time/app/js/DocumentUpdaterController.js b/services/real-time/app/js/DocumentUpdaterController.js index 3fd22d3ebd..397872479e 100644 --- a/services/real-time/app/js/DocumentUpdaterController.js +++ b/services/real-time/app/js/DocumentUpdaterController.js @@ -1,15 +1,16 @@ -const logger = require('@overleaf/logger') -const settings = require('@overleaf/settings') -const RedisClientManager = require('./RedisClientManager') -const SafeJsonParse = require('./SafeJsonParse') -const EventLogger = require('./EventLogger') -const HealthCheckManager = require('./HealthCheckManager') -const RoomManager = require('./RoomManager') -const ChannelManager = require('./ChannelManager') -const metrics = require('@overleaf/metrics') +import logger from '@overleaf/logger' +import settings from '@overleaf/settings' +import RedisClientManager from './RedisClientManager.js' +import SafeJsonParse from './SafeJsonParse.js' +import EventLogger from './EventLogger.js' +import HealthCheckManager from './HealthCheckManager.js' +import RoomManager from './RoomManager.js' +import ChannelManager from './ChannelManager.js' +import metrics from '@overleaf/metrics' let DocumentUpdaterController -module.exports = DocumentUpdaterController = { + +export default DocumentUpdaterController = { // DocumentUpdaterController is responsible for updates that come via Redis // Pub/Sub from the document updater. rclientList: RedisClientManager.createClientList(settings.redis.pubsub), diff --git a/services/real-time/app/js/DocumentUpdaterManager.js b/services/real-time/app/js/DocumentUpdaterManager.js index 51b71e8ec0..006777395c 100644 --- a/services/real-time/app/js/DocumentUpdaterManager.js +++ b/services/real-time/app/js/DocumentUpdaterManager.js @@ -1,19 +1,19 @@ -const request = require('request') -const _ = require('lodash') -const OError = require('@overleaf/o-error') -const logger = require('@overleaf/logger') -const settings = require('@overleaf/settings') -const metrics = require('@overleaf/metrics') +import request from 'request' +import _ from 'lodash' +import OError from '@overleaf/o-error' +import logger from '@overleaf/logger' +import settings from '@overleaf/settings' +import metrics from '@overleaf/metrics' +import RedisWrapper from '@overleaf/redis-wrapper' +import Errors from './Errors.js' + const { ClientRequestedMissingOpsError, DocumentUpdaterRequestFailedError, NullBytesInOpError, UpdateTooLargeError, -} = require('./Errors') - -const rclient = require('@overleaf/redis-wrapper').createClient( - settings.redis.documentupdater -) +} = Errors +const rclient = RedisWrapper.createClient(settings.redis.documentupdater) const Keys = settings.redis.documentupdater.key_schema const DocumentUpdaterManager = { @@ -154,4 +154,4 @@ const DocumentUpdaterManager = { }, } -module.exports = DocumentUpdaterManager +export default DocumentUpdaterManager diff --git a/services/real-time/app/js/DrainManager.js b/services/real-time/app/js/DrainManager.js index c8fc72ce99..686b43db72 100644 --- a/services/real-time/app/js/DrainManager.js +++ b/services/real-time/app/js/DrainManager.js @@ -1,6 +1,6 @@ -const logger = require('@overleaf/logger') +import logger from '@overleaf/logger' -module.exports = { +export default { startDrainTimeWindow(io, minsToDrain, callback) { const drainPerMin = io.sockets.clients().length / minsToDrain // enforce minimum drain rate diff --git a/services/real-time/app/js/Errors.js b/services/real-time/app/js/Errors.js index fea0cc643a..e3025a5a47 100644 --- a/services/real-time/app/js/Errors.js +++ b/services/real-time/app/js/Errors.js @@ -1,4 +1,4 @@ -const OError = require('@overleaf/o-error') +import OError from '@overleaf/o-error' class ClientRequestedMissingOpsError extends OError { constructor(statusCode, info = {}) { @@ -87,7 +87,7 @@ class WebApiRequestFailedError extends OError { } } -module.exports = { +export default { CodedError, CorruptedJoinProjectResponseError, ClientRequestedMissingOpsError, diff --git a/services/real-time/app/js/EventLogger.js b/services/real-time/app/js/EventLogger.js index 44496eb5ad..faca6bb8e2 100644 --- a/services/real-time/app/js/EventLogger.js +++ b/services/real-time/app/js/EventLogger.js @@ -1,7 +1,7 @@ +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import settings from '@overleaf/settings' let EventLogger -const logger = require('@overleaf/logger') -const metrics = require('@overleaf/metrics') -const settings = require('@overleaf/settings') // keep track of message counters to detect duplicate and out of order events // messsage ids have the format "UNIQUEHOSTKEY-COUNTER" @@ -13,7 +13,7 @@ let EVENT_LAST_CLEAN_TIMESTAMP = 0 // counter for debug logs let COUNTER = 0 -module.exports = EventLogger = { +export default EventLogger = { MAX_STALE_TIME_IN_MS: 3600 * 1000, debugEvent(channel, message) { diff --git a/services/real-time/app/js/HealthCheckManager.js b/services/real-time/app/js/HealthCheckManager.js index 4ced9e04e2..db37003a9c 100644 --- a/services/real-time/app/js/HealthCheckManager.js +++ b/services/real-time/app/js/HealthCheckManager.js @@ -1,7 +1,6 @@ -const metrics = require('@overleaf/metrics') -const logger = require('@overleaf/logger') - -const os = require('node:os') +import metrics from '@overleaf/metrics' +import logger from '@overleaf/logger' +import os from 'node:os' const HOST = os.hostname() const PID = process.pid let COUNT = 0 @@ -9,7 +8,7 @@ let COUNT = 0 const CHANNEL_MANAGER = {} // hash of event checkers by channel name const CHANNEL_ERROR = {} // error status by channel name -module.exports = class HealthCheckManager { +export default class HealthCheckManager { // create an instance of this class which checks that an event with a unique // id is received only once within a timeout constructor(channel, timeout) { diff --git a/services/real-time/app/js/HttpApiController.js b/services/real-time/app/js/HttpApiController.js index 5e75fe3601..beb6a0692d 100644 --- a/services/real-time/app/js/HttpApiController.js +++ b/services/real-time/app/js/HttpApiController.js @@ -1,9 +1,9 @@ -const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') -const DrainManager = require('./DrainManager') -const ConnectedUsersManager = require('./ConnectedUsersManager') -const logger = require('@overleaf/logger') +import WebsocketLoadBalancer from './WebsocketLoadBalancer.js' +import DrainManager from './DrainManager.js' +import ConnectedUsersManager from './ConnectedUsersManager.js' +import logger from '@overleaf/logger' -module.exports = { +export default { countConnectedClients(req, res) { const { projectId } = req.params ConnectedUsersManager.countConnectedClients( diff --git a/services/real-time/app/js/HttpController.js b/services/real-time/app/js/HttpController.js index 79978ed02d..62c90f0cb7 100644 --- a/services/real-time/app/js/HttpController.js +++ b/services/real-time/app/js/HttpController.js @@ -1,5 +1,6 @@ let HttpController -module.exports = HttpController = { + +export default HttpController = { // The code in this controller is hard to unit test because of a lot of // dependencies on internal socket.io methods. It is not critical to the running // of Overleaf, and is only used for getting stats about connected clients, diff --git a/services/real-time/app/js/RedisClientManager.js b/services/real-time/app/js/RedisClientManager.js index 63eccef106..6dda0545f5 100644 --- a/services/real-time/app/js/RedisClientManager.js +++ b/services/real-time/app/js/RedisClientManager.js @@ -1,7 +1,7 @@ -const redis = require('@overleaf/redis-wrapper') -const logger = require('@overleaf/logger') +import redis from '@overleaf/redis-wrapper' +import logger from '@overleaf/logger' -module.exports = { +export default { createClientList(...configs) { // create a dynamic list of redis clients, excluding any configurations which are not defined return configs.filter(Boolean).map(x => { diff --git a/services/real-time/app/js/RoomManager.js b/services/real-time/app/js/RoomManager.js index 47302d5ddb..e032b547df 100644 --- a/services/real-time/app/js/RoomManager.js +++ b/services/real-time/app/js/RoomManager.js @@ -1,7 +1,7 @@ -const logger = require('@overleaf/logger') -const metrics = require('@overleaf/metrics') -const { EventEmitter } = require('node:events') -const OError = require('@overleaf/o-error') +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import { EventEmitter } from 'node:events' +import OError from '@overleaf/o-error' const IdMap = new Map() // keep track of whether ids are from projects or docs const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events @@ -16,7 +16,7 @@ const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project // // The pubsub side is handled by ChannelManager -module.exports = { +export default { joinProject(client, projectId, callback) { this.joinEntity(client, 'project', projectId, callback) }, diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js index be70beb7f4..e29873fcbf 100644 --- a/services/real-time/app/js/Router.js +++ b/services/real-time/app/js/Router.js @@ -1,17 +1,20 @@ -const metrics = require('@overleaf/metrics') -const logger = require('@overleaf/logger') -const settings = require('@overleaf/settings') -const WebsocketController = require('./WebsocketController') -const HttpController = require('./HttpController') -const HttpApiController = require('./HttpApiController') -const WebsocketAddressManager = require('./WebsocketAddressManager') -const bodyParser = require('body-parser') -const base64id = require('base64id') -const { UnexpectedArgumentsError } = require('./Errors') -const { z, zz } = require('@overleaf/validation-tools') -const { isZodErrorLike } = require('zod-validation-error') +import metrics from '@overleaf/metrics' +import logger from '@overleaf/logger' +import settings from '@overleaf/settings' +import WebsocketController from './WebsocketController.js' +import HttpController from './HttpController.js' +import HttpApiController from './HttpApiController.js' +import WebsocketAddressManager from './WebsocketAddressManager.js' +import bodyParser from 'body-parser' +import base64id from 'base64id' +import Errors from './Errors.js' +import { z, zz } from '@overleaf/validation-tools' +import { isZodErrorLike } from 'zod-validation-error' +import os from 'node:os' -const HOSTNAME = require('node:os').hostname() +const { UnexpectedArgumentsError } = Errors + +const HOSTNAME = os.hostname() const SERVER_PING_INTERVAL = 15000 const SERVER_PING_LATENCY_THRESHOLD = 5000 @@ -27,7 +30,8 @@ const applyOtUpdateSchema = z.object({ }) let Router -module.exports = Router = { + +export default Router = { _handleError(callback, error, client, method, attrs) { attrs = attrs || {} for (const key of ['project_id', 'user_id']) { diff --git a/services/real-time/app/js/SafeJsonParse.js b/services/real-time/app/js/SafeJsonParse.js index bc7a6bed10..1666c3a46d 100644 --- a/services/real-time/app/js/SafeJsonParse.js +++ b/services/real-time/app/js/SafeJsonParse.js @@ -1,7 +1,9 @@ -const Settings = require('@overleaf/settings') -const { DataTooLargeToParseError } = require('./Errors') +import Settings from '@overleaf/settings' +import Errors from './Errors.js' -module.exports = { +const { DataTooLargeToParseError } = Errors + +export default { parse(data, callback) { if (data.length > Settings.maxUpdateSize) { return callback(new DataTooLargeToParseError(data)) diff --git a/services/real-time/app/js/SessionSockets.js b/services/real-time/app/js/SessionSockets.js index c454ccbc75..d56ead1b00 100644 --- a/services/real-time/app/js/SessionSockets.js +++ b/services/real-time/app/js/SessionSockets.js @@ -1,9 +1,11 @@ -const metrics = require('@overleaf/metrics') -const OError = require('@overleaf/o-error') -const { EventEmitter } = require('node:events') -const { MissingSessionError } = require('./Errors') +import metrics from '@overleaf/metrics' +import OError from '@overleaf/o-error' +import { EventEmitter } from 'node:events' +import Errors from './Errors.js' -module.exports = function (io, sessionStore, cookieParser, cookieName) { +const { MissingSessionError } = Errors + +export default function (io, sessionStore, cookieParser, cookieName) { const missingSessionError = new MissingSessionError() const sessionSockets = new EventEmitter() diff --git a/services/real-time/app/js/WebApiManager.js b/services/real-time/app/js/WebApiManager.js index efc7092987..08b5cdb99c 100644 --- a/services/real-time/app/js/WebApiManager.js +++ b/services/real-time/app/js/WebApiManager.js @@ -1,15 +1,17 @@ -const request = require('request') -const OError = require('@overleaf/o-error') -const settings = require('@overleaf/settings') -const logger = require('@overleaf/logger') +import request from 'request' +import OError from '@overleaf/o-error' +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import Errors from './Errors.js' + const { CodedError, CorruptedJoinProjectResponseError, NotAuthorizedError, WebApiRequestFailedError, -} = require('./Errors') +} = Errors -module.exports = { +export default { joinProject(projectId, user, callback) { const userId = user._id logger.debug({ projectId, userId }, 'sending join project request to web') diff --git a/services/real-time/app/js/WebsocketAddressManager.js b/services/real-time/app/js/WebsocketAddressManager.js index d01f081580..9b5b65ce23 100644 --- a/services/real-time/app/js/WebsocketAddressManager.js +++ b/services/real-time/app/js/WebsocketAddressManager.js @@ -1,6 +1,6 @@ -const proxyaddr = require('proxy-addr') +import proxyaddr from 'proxy-addr' -module.exports = class WebsocketAddressManager { +export default class WebsocketAddressManager { constructor(behindProxy, trustedProxyIps) { if (behindProxy) { // parse trustedProxyIps comma-separated list the same way as express diff --git a/services/real-time/app/js/WebsocketController.js b/services/real-time/app/js/WebsocketController.js index c0f465a490..3c963f233e 100644 --- a/services/real-time/app/js/WebsocketController.js +++ b/services/real-time/app/js/WebsocketController.js @@ -1,19 +1,21 @@ -const OError = require('@overleaf/o-error') -const logger = require('@overleaf/logger') -const metrics = require('@overleaf/metrics') -const WebApiManager = require('./WebApiManager') -const AuthorizationManager = require('./AuthorizationManager') -const DocumentUpdaterManager = require('./DocumentUpdaterManager') -const ConnectedUsersManager = require('./ConnectedUsersManager') -const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') -const RoomManager = require('./RoomManager') +import OError from '@overleaf/o-error' +import logger from '@overleaf/logger' +import metrics from '@overleaf/metrics' +import WebApiManager from './WebApiManager.js' +import AuthorizationManager from './AuthorizationManager.js' +import DocumentUpdaterManager from './DocumentUpdaterManager.js' +import ConnectedUsersManager from './ConnectedUsersManager.js' +import WebsocketLoadBalancer from './WebsocketLoadBalancer.js' +import RoomManager from './RoomManager.js' +import Errors from './Errors.js' + const { CodedError, JoinLeaveEpochMismatchError, NotAuthorizedError, NotJoinedError, ClientRequestedMissingOpsError, -} = require('./Errors') +} = Errors const JOIN_DOC_CATCH_UP_LENGTH_BUCKETS = [ 0, 5, 10, 25, 50, 100, 150, 200, 250, 500, 1000, @@ -35,7 +37,8 @@ const JOIN_DOC_CATCH_UP_AGE = [ ].map(x => x * 1000) let WebsocketController -module.exports = WebsocketController = { + +export default WebsocketController = { // If the protocol version changes when the client reconnects, // it will force a full refresh of the page. Useful for non-backwards // compatible protocol changes. Use only in extreme need. diff --git a/services/real-time/app/js/WebsocketLoadBalancer.js b/services/real-time/app/js/WebsocketLoadBalancer.js index ebf20fa11f..f6a7d78c89 100644 --- a/services/real-time/app/js/WebsocketLoadBalancer.js +++ b/services/real-time/app/js/WebsocketLoadBalancer.js @@ -1,13 +1,13 @@ -const Settings = require('@overleaf/settings') -const logger = require('@overleaf/logger') -const Metrics = require('@overleaf/metrics') -const RedisClientManager = require('./RedisClientManager') -const SafeJsonParse = require('./SafeJsonParse') -const EventLogger = require('./EventLogger') -const HealthCheckManager = require('./HealthCheckManager') -const RoomManager = require('./RoomManager') -const ChannelManager = require('./ChannelManager') -const ConnectedUsersManager = require('./ConnectedUsersManager') +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import RedisClientManager from './RedisClientManager.js' +import SafeJsonParse from './SafeJsonParse.js' +import EventLogger from './EventLogger.js' +import HealthCheckManager from './HealthCheckManager.js' +import RoomManager from './RoomManager.js' +import ChannelManager from './ChannelManager.js' +import ConnectedUsersManager from './ConnectedUsersManager.js' const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [ 'otUpdateApplied', @@ -32,7 +32,8 @@ for (let i = 5; i <= 22; i++) { } let WebsocketLoadBalancer -module.exports = WebsocketLoadBalancer = { + +export default WebsocketLoadBalancer = { rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub), rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub), diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt index afcbb339c4..a096b0d59b 100644 --- a/services/real-time/buildscript.txt +++ b/services/real-time/buildscript.txt @@ -6,3 +6,5 @@ real-time --node-version=22.18.0 --pipeline-owner=🚉 Platform --public-repo=False +--test-unit-vitest=True +--tsconfig-extra-includes=vitest.config.unit.cjs diff --git a/services/real-time/config/settings.defaults.js b/services/real-time/config/settings.defaults.cjs similarity index 100% rename from services/real-time/config/settings.defaults.js rename to services/real-time/config/settings.defaults.cjs diff --git a/services/real-time/config/settings.test.js b/services/real-time/config/settings.test.cjs similarity index 100% rename from services/real-time/config/settings.test.js rename to services/real-time/config/settings.test.cjs diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml index be3b6ebee8..e344d2e775 100644 --- a/services/real-time/docker-compose.ci.yml +++ b/services/real-time/docker-compose.ci.yml @@ -8,12 +8,14 @@ services: user: node volumes: - ./reports:/overleaf/services/real-time/reports + - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json command: npm run test:unit:_run environment: CI: MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf NODE_ENV: test NODE_OPTIONS: "--unhandled-rejections=strict" + VITEST_NO_CACHE: true REDIS_HOST: redis_test QUEUES_REDIS_HOST: redis_test HISTORY_REDIS_HOST: redis_test diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml index 2678561151..1290af579f 100644 --- a/services/real-time/docker-compose.yml +++ b/services/real-time/docker-compose.yml @@ -9,6 +9,7 @@ services: - .:/overleaf/services/real-time - ../../node_modules:/overleaf/node_modules - ../../libraries:/overleaf/libraries + - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json working_dir: /overleaf/services/real-time environment: MOCHA_GREP: ${MOCHA_GREP} diff --git a/services/real-time/package.json b/services/real-time/package.json index de90f08bad..f1bbfa5fc7 100644 --- a/services/real-time/package.json +++ b/services/real-time/package.json @@ -3,12 +3,13 @@ "description": "The socket.io layer of Overleaf for real-time editor interactions", "private": true, "main": "app.js", + "type": "module", "scripts": { "start": "node app.js", "test:acceptance:_run": "mocha --recursive --timeout 15000 --exit $@ test/acceptance/js", "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", - "test:unit:_run": "mocha --recursive --exit $@ test/unit/js", - "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", + "test:unit:_run": "vitest --config ./vitest.config.unit.cjs", + "test:unit": "npm run test:unit:_run", "nodemon": "node --watch app.js", "lint": "eslint --max-warnings 0 --format unix .", "format": "prettier --list-different $PWD/'**/{*.*js,*.ts}'", @@ -46,11 +47,11 @@ "mocha": "^11.1.0", "mocha-junit-reporter": "^2.2.1", "mocha-multi-reporters": "^1.5.1", - "sandboxed-module": "~0.3.0", "sinon": "^9.2.4", - "sinon-chai": "^3.7.0", + "sinon-chai": "4.0.1", "timekeeper": "0.0.4", "typescript": "^5.0.4", - "uid-safe": "^2.1.5" + "uid-safe": "^2.1.5", + "vitest": "^4.0.0" } } diff --git a/services/real-time/test/acceptance/js/ApplyUpdateTests.js b/services/real-time/test/acceptance/js/ApplyUpdateTests.js index c3e47ed409..cf854d11fe 100644 --- a/services/real-time/test/acceptance/js/ApplyUpdateTests.js +++ b/services/real-time/test/acceptance/js/ApplyUpdateTests.js @@ -10,14 +10,13 @@ * DS201: Simplify complex destructure assignments * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const async = require('async') -const { expect } = require('chai') +import async from 'async' -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') - -const settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') +import { expect } from 'chai' +import RealTimeClient from './helpers/RealTimeClient.js' +import FixturesManager from './helpers/FixturesManager.js' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' const rclient = redis.createClient(settings.redis.documentupdater) const redisSettings = settings.redis diff --git a/services/real-time/test/acceptance/js/ClientTrackingTests.js b/services/real-time/test/acceptance/js/ClientTrackingTests.js index d4b484c0a8..a38d8d7ea1 100644 --- a/services/real-time/test/acceptance/js/ClientTrackingTests.js +++ b/services/real-time/test/acceptance/js/ClientTrackingTests.js @@ -10,13 +10,12 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const MockWebServer = require('./helpers/MockWebServer') -const FixturesManager = require('./helpers/FixturesManager') - -const async = require('async') +import RealTimeClient from './helpers/RealTimeClient.js' +import MockWebServer from './helpers/MockWebServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' describe('clientTracking', function () { describe('when another logged in user joins a project', function () { diff --git a/services/real-time/test/acceptance/js/DrainManagerTests.js b/services/real-time/test/acceptance/js/DrainManagerTests.js index 99502e2246..8b10be3eed 100644 --- a/services/real-time/test/acceptance/js/DrainManagerTests.js +++ b/services/real-time/test/acceptance/js/DrainManagerTests.js @@ -5,13 +5,12 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') +import RealTimeClient from './helpers/RealTimeClient.js' -const { expect } = require('chai') - -const async = require('async') -const request = require('request') +import FixturesManager from './helpers/FixturesManager.js' +import { expect } from 'chai' +import async from 'async' +import request from 'request' const drain = function (rate, callback) { request.post( diff --git a/services/real-time/test/acceptance/js/EarlyDisconnect.js b/services/real-time/test/acceptance/js/EarlyDisconnect.js index 84c9f89c57..44e328c470 100644 --- a/services/real-time/test/acceptance/js/EarlyDisconnect.js +++ b/services/real-time/test/acceptance/js/EarlyDisconnect.js @@ -9,16 +9,15 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const async = require('async') -const { expect } = require('chai') +import async from 'async' -const RealTimeClient = require('./helpers/RealTimeClient') -const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') -const MockWebServer = require('./helpers/MockWebServer') -const FixturesManager = require('./helpers/FixturesManager') - -const settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') +import { expect } from 'chai' +import RealTimeClient from './helpers/RealTimeClient.js' +import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js' +import MockWebServer from './helpers/MockWebServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' const rclient = redis.createClient(settings.redis.pubsub) const rclientRT = redis.createClient(settings.redis.realtime) const KeysRT = settings.redis.realtime.key_schema diff --git a/services/real-time/test/acceptance/js/HttpControllerTests.js b/services/real-time/test/acceptance/js/HttpControllerTests.js index ce84b1a280..8a7ce19266 100644 --- a/services/real-time/test/acceptance/js/HttpControllerTests.js +++ b/services/real-time/test/acceptance/js/HttpControllerTests.js @@ -5,20 +5,21 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const async = require('async') -const { expect } = require('chai') -const request = require('request').defaults({ +import async from 'async' +import Request from 'request' +import { expect } from 'chai' +import RealTimeClient from './helpers/RealTimeClient.js' +import FixturesManager from './helpers/FixturesManager.js' + +const request = Request.defaults({ baseUrl: 'http://127.0.0.1:3026', }) -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') - describe('HttpControllerTests', function () { describe('without a user', function () { - return it('should return 404 for the client view', function (done) { + it('should return 404 for the client view', function (done) { const clientId = 'not-existing' - return request.get( + request.get( { url: `/clients/${clientId}`, json: true, @@ -28,36 +29,36 @@ describe('HttpControllerTests', function () { return done(error) } expect(response.statusCode).to.equal(404) - return done() + done() } ) }) }) - return describe('with a user and after joining a project', function () { + describe('with a user and after joining a project', function () { before(function (done) { - return async.series( + async.series( [ cb => { - return FixturesManager.setUpProject( + FixturesManager.setUpProject( { privilegeLevel: 'owner', }, (error, { project_id: projectId, user_id: userId }) => { this.project_id = projectId this.user_id = userId - return cb(error) + cb(error) } ) }, cb => { - return FixturesManager.setUpDoc( + FixturesManager.setUpDoc( this.project_id, {}, (error, { doc_id: docId }) => { this.doc_id = docId - return cb(error) + cb(error) } ) }, @@ -67,15 +68,15 @@ describe('HttpControllerTests', function () { }, cb => { - return this.client.emit('joinDoc', this.doc_id, cb) + this.client.emit('joinDoc', this.doc_id, cb) }, ], done ) }) - return it('should send a client view', function (done) { - return request.get( + it('should send a client view', function (done) { + request.get( { url: `/clients/${this.client.socket.sessionid}`, json: true, @@ -97,7 +98,7 @@ describe('HttpControllerTests', function () { user_id: this.user_id, rooms: [this.project_id, this.doc_id], }) - return done() + done() } ) }) diff --git a/services/real-time/test/acceptance/js/JoinDocTests.js b/services/real-time/test/acceptance/js/JoinDocTests.js index 2616c7c0e6..2b7e924435 100644 --- a/services/real-time/test/acceptance/js/JoinDocTests.js +++ b/services/real-time/test/acceptance/js/JoinDocTests.js @@ -9,13 +9,12 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') -const FixturesManager = require('./helpers/FixturesManager') - -const async = require('async') +import RealTimeClient from './helpers/RealTimeClient.js' +import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' describe('joinDoc', function () { before(function () { diff --git a/services/real-time/test/acceptance/js/JoinProjectTests.js b/services/real-time/test/acceptance/js/JoinProjectTests.js index bfb354c494..f493e233ff 100644 --- a/services/real-time/test/acceptance/js/JoinProjectTests.js +++ b/services/real-time/test/acceptance/js/JoinProjectTests.js @@ -6,13 +6,12 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const MockWebServer = require('./helpers/MockWebServer') -const FixturesManager = require('./helpers/FixturesManager') - -const async = require('async') +import RealTimeClient from './helpers/RealTimeClient.js' +import MockWebServer from './helpers/MockWebServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' describe('joinProject', function () { describe('when authorized', function () { diff --git a/services/real-time/test/acceptance/js/LeaveDocTests.js b/services/real-time/test/acceptance/js/LeaveDocTests.js index 13a0236ec3..5bb573631f 100644 --- a/services/real-time/test/acceptance/js/LeaveDocTests.js +++ b/services/real-time/test/acceptance/js/LeaveDocTests.js @@ -11,15 +11,14 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') -const sinon = require('sinon') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') -const FixturesManager = require('./helpers/FixturesManager') -const logger = require('@overleaf/logger') - -const async = require('async') +import sinon from 'sinon' +import RealTimeClient from './helpers/RealTimeClient.js' +import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import logger from '@overleaf/logger' +import async from 'async' describe('leaveDoc', function () { before(function () { diff --git a/services/real-time/test/acceptance/js/LeaveProjectTests.js b/services/real-time/test/acceptance/js/LeaveProjectTests.js index bf0642160f..5e230bbb64 100644 --- a/services/real-time/test/acceptance/js/LeaveProjectTests.js +++ b/services/real-time/test/acceptance/js/LeaveProjectTests.js @@ -9,14 +9,13 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const RealTimeClient = require('./helpers/RealTimeClient') -const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') -const FixturesManager = require('./helpers/FixturesManager') +import RealTimeClient from './helpers/RealTimeClient.js' -const async = require('async') - -const settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') +import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' const rclient = redis.createClient(settings.redis.pubsub) describe('leaveProject', function () { diff --git a/services/real-time/test/acceptance/js/MatrixTests.js b/services/real-time/test/acceptance/js/MatrixTests.js index 5f4b4e6867..75b8a74bac 100644 --- a/services/real-time/test/acceptance/js/MatrixTests.js +++ b/services/real-time/test/acceptance/js/MatrixTests.js @@ -45,16 +45,16 @@ There is additional meta-data that UserItems and SessionItems may use to skip SessionItem: { needsOwnProject: true } */ -const { expect } = require('chai') -const async = require('async') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') -const MockWebServer = require('./helpers/MockWebServer') +import async from 'async' +import RealTimeClient from './helpers/RealTimeClient.js' +import FixturesManager from './helpers/FixturesManager.js' +import MockWebServer from './helpers/MockWebServer.js' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' -const settings = require('@overleaf/settings') const Keys = settings.redis.documentupdater.key_schema -const redis = require('@overleaf/redis-wrapper') const rclient = redis.createClient(settings.redis.pubsub) function getPendingUpdates(docId, cb) { diff --git a/services/real-time/test/acceptance/js/PubSubRace.js b/services/real-time/test/acceptance/js/PubSubRace.js index fc0fe6418f..087cc43afb 100644 --- a/services/real-time/test/acceptance/js/PubSubRace.js +++ b/services/real-time/test/acceptance/js/PubSubRace.js @@ -8,14 +8,13 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const RealTimeClient = require('./helpers/RealTimeClient') -const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') -const FixturesManager = require('./helpers/FixturesManager') +import RealTimeClient from './helpers/RealTimeClient.js' -const async = require('async') - -const settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') +import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' const rclient = redis.createClient(settings.redis.pubsub) describe('PubSubRace', function () { diff --git a/services/real-time/test/acceptance/js/ReceiveEditorEventTests.js b/services/real-time/test/acceptance/js/ReceiveEditorEventTests.js index 7e9fd938a1..aec4f78263 100644 --- a/services/real-time/test/acceptance/js/ReceiveEditorEventTests.js +++ b/services/real-time/test/acceptance/js/ReceiveEditorEventTests.js @@ -9,15 +9,13 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') - -const async = require('async') - -const settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') +import RealTimeClient from './helpers/RealTimeClient.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' const rclient = redis.createClient(settings.redis.pubsub) describe('receiveEditorEvent', function () { diff --git a/services/real-time/test/acceptance/js/ReceiveUpdateTests.js b/services/real-time/test/acceptance/js/ReceiveUpdateTests.js index 6c7367a08f..a7b07835c4 100644 --- a/services/real-time/test/acceptance/js/ReceiveUpdateTests.js +++ b/services/real-time/test/acceptance/js/ReceiveUpdateTests.js @@ -9,16 +9,14 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') +import { expect } from 'chai' -const RealTimeClient = require('./helpers/RealTimeClient') -const MockWebServer = require('./helpers/MockWebServer') -const FixturesManager = require('./helpers/FixturesManager') - -const async = require('async') - -const settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') +import RealTimeClient from './helpers/RealTimeClient.js' +import MockWebServer from './helpers/MockWebServer.js' +import FixturesManager from './helpers/FixturesManager.js' +import async from 'async' +import settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' const rclient = redis.createClient(settings.redis.pubsub) describe('receiveUpdate', function () { diff --git a/services/real-time/test/acceptance/js/RouterTests.js b/services/real-time/test/acceptance/js/RouterTests.js index e3493a6f65..7638d197a5 100644 --- a/services/real-time/test/acceptance/js/RouterTests.js +++ b/services/real-time/test/acceptance/js/RouterTests.js @@ -5,11 +5,11 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const async = require('async') -const { expect } = require('chai') +import async from 'async' -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') +import { expect } from 'chai' +import RealTimeClient from './helpers/RealTimeClient.js' +import FixturesManager from './helpers/FixturesManager.js' describe('Router', function () { return describe('joinProject', function () { diff --git a/services/real-time/test/acceptance/js/SessionSocketsTests.js b/services/real-time/test/acceptance/js/SessionSocketsTests.js index cca7f75d07..dcb7beb243 100644 --- a/services/real-time/test/acceptance/js/SessionSocketsTests.js +++ b/services/real-time/test/acceptance/js/SessionSocketsTests.js @@ -1,8 +1,8 @@ -const RealTimeClient = require('./helpers/RealTimeClient') -const FixturesManager = require('./helpers/FixturesManager') -const Settings = require('@overleaf/settings') -const signature = require('cookie-signature') -const { expect } = require('chai') +import RealTimeClient from './helpers/RealTimeClient.js' +import FixturesManager from './helpers/FixturesManager.js' +import Settings from '@overleaf/settings' +import signature from 'cookie-signature' +import { expect } from 'chai' describe('SessionSockets', function () { beforeEach(function (done) { diff --git a/services/real-time/test/acceptance/js/SessionTests.js b/services/real-time/test/acceptance/js/SessionTests.js index 819ec26514..1e5c47bb23 100644 --- a/services/real-time/test/acceptance/js/SessionTests.js +++ b/services/real-time/test/acceptance/js/SessionTests.js @@ -10,10 +10,10 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { expect } = require('chai') +import { expect } from 'chai' -const FixturesManager = require('./helpers/FixturesManager') -const RealTimeClient = require('./helpers/RealTimeClient') +import FixturesManager from './helpers/FixturesManager.js' +import RealTimeClient from './helpers/RealTimeClient.js' describe('Session', function () { return describe('with an established session', function () { diff --git a/services/real-time/test/acceptance/js/helpers/FixturesManager.js b/services/real-time/test/acceptance/js/helpers/FixturesManager.js index 66e3072532..4e37388f1b 100644 --- a/services/real-time/test/acceptance/js/helpers/FixturesManager.js +++ b/services/real-time/test/acceptance/js/helpers/FixturesManager.js @@ -6,12 +6,14 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -let FixturesManager -const RealTimeClient = require('./RealTimeClient') -const MockWebServer = require('./MockWebServer') -const MockDocUpdaterServer = require('./MockDocUpdaterServer') +import RealTimeClient from './RealTimeClient.js' +import MockWebServer from './MockWebServer.js' +import MockDocUpdaterServer from './MockDocUpdaterServer.js' +import crypto from 'node:crypto' -module.exports = FixturesManager = { +let FixturesManager + +export default FixturesManager = { setUpProject(options, callback) { if (options == null) { options = {} @@ -151,7 +153,7 @@ module.exports = FixturesManager = { }, getRandomId() { - return require('node:crypto') + return crypto .createHash('sha1') .update(Math.random().toString()) .digest('hex') diff --git a/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js b/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js index 29d57189ef..a115470633 100644 --- a/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js +++ b/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js @@ -9,11 +9,12 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -let MockDocUpdaterServer -const sinon = require('sinon') -const express = require('express') +import sinon from 'sinon' +import express from 'express' -module.exports = MockDocUpdaterServer = { +let MockDocUpdaterServer + +export default MockDocUpdaterServer = { docs: {}, createMockDoc(projectId, docId, data) { diff --git a/services/real-time/test/acceptance/js/helpers/MockWebServer.js b/services/real-time/test/acceptance/js/helpers/MockWebServer.js index 138db1fe08..4b877ded31 100644 --- a/services/real-time/test/acceptance/js/helpers/MockWebServer.js +++ b/services/real-time/test/acceptance/js/helpers/MockWebServer.js @@ -9,12 +9,13 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -let MockWebServer -const sinon = require('sinon') -const express = require('express') -const bodyParser = require('body-parser') +import sinon from 'sinon' +import express from 'express' +import bodyParser from 'body-parser' -module.exports = MockWebServer = { +let MockWebServer + +export default MockWebServer = { projects: {}, privileges: {}, userMetadata: {}, diff --git a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js index 4c6bd89223..6e91b45445 100644 --- a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js +++ b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js @@ -1,14 +1,16 @@ -const { XMLHttpRequest } = require('../../libs/XMLHttpRequest') -const io = require('socket.io-client') +import io from 'socket.io-client' + +import Settings from '@overleaf/settings' +import redis from '@overleaf/redis-wrapper' + +import uidSafe from 'uid-safe' +import signature from 'cookie-signature' +import { callbackify } from 'node:util' +import { fetchJson, fetchNothing } from '@overleaf/fetch-utils' +import { XMLHttpRequest } from '../../libs/XMLHttpRequest.js' -const Settings = require('@overleaf/settings') -const redis = require('@overleaf/redis-wrapper') const rclient = redis.createClient(Settings.redis.websessions) - -const uid = require('uid-safe').sync -const signature = require('cookie-signature') -const { callbackify } = require('node:util') -const { fetchJson, fetchNothing } = require('@overleaf/fetch-utils') +const uid = uidSafe.sync io.util.request = function () { const xhr = new XMLHttpRequest() @@ -150,4 +152,4 @@ const Client = { }, } -module.exports = Client +export default Client diff --git a/services/real-time/test/acceptance/js/helpers/RealtimeServer.js b/services/real-time/test/acceptance/js/helpers/RealtimeServer.js deleted file mode 100644 index be3a6f43b8..0000000000 --- a/services/real-time/test/acceptance/js/helpers/RealtimeServer.js +++ /dev/null @@ -1,61 +0,0 @@ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * DS103: Rewrite code to no longer use __guard__ - * DS205: Consider reworking code to avoid use of IIFEs - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const app = require('../../../../app') -const logger = require('@overleaf/logger') -const Settings = require('@overleaf/settings') - -module.exports = { - running: false, - initing: false, - callbacks: [], - ensureRunning(callback) { - if (callback == null) { - callback = function () {} - } - if (this.running) { - return callback() - } else if (this.initing) { - return this.callbacks.push(callback) - } else { - this.initing = true - this.callbacks.push(callback) - return app.listen( - __guard__( - Settings.internal != null ? Settings.internal.realtime : undefined, - x => x.port - ), - '127.0.0.1', - error => { - if (error != null) { - throw error - } - this.running = true - logger.info('clsi running in dev mode') - - return (() => { - const result = [] - for (callback of Array.from(this.callbacks)) { - result.push(callback()) - } - return result - })() - } - ) - } - }, -} - -function __guard__(value, transform) { - return typeof value !== 'undefined' && value !== null - ? transform(value) - : undefined -} diff --git a/services/real-time/test/acceptance/libs/XMLHttpRequest.js b/services/real-time/test/acceptance/libs/XMLHttpRequest.js index 3586f44963..f5660b398b 100644 --- a/services/real-time/test/acceptance/libs/XMLHttpRequest.js +++ b/services/real-time/test/acceptance/libs/XMLHttpRequest.js @@ -11,17 +11,17 @@ * @license MIT */ -const { URL } = require('node:url') -const spawn = require('node:child_process').spawn -const fs = require('node:fs') +import { URL } from 'node:url' +import http from 'node:http' +import https from 'node:https' +import { spawn } from 'node:child_process' +import fs from 'node:fs' -exports.XMLHttpRequest = function () { +export const XMLHttpRequest = function () { /** * Private variables */ const self = this - const http = require('node:http') - const https = require('node:https') // Holds http.js objects let request diff --git a/services/real-time/test/setup.js b/services/real-time/test/setup.js index c213049d84..3c6b19c60b 100644 --- a/services/real-time/test/setup.js +++ b/services/real-time/test/setup.js @@ -1,8 +1,8 @@ -const chai = require('chai') -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const chaiAsPromised = require('chai-as-promised') -const sinonChai = require('sinon-chai') +import * as chai from 'chai' +import sinon from 'sinon' +import chaiAsPromised from 'chai-as-promised' +import sinonChai from 'sinon-chai' +import '../app.js' // Chai configuration chai.should() @@ -22,21 +22,8 @@ const stubs = { }, } -// SandboxedModule configuration -SandboxedModule.configure({ - requires: { - '@overleaf/logger': stubs.logger, - }, - globals: { Buffer, JSON, console, process }, - sourceTransformers: { - removeNodePrefix: function (source) { - return source.replace(/require\(['"]node:/g, "require('") - }, - }, -}) - // Mocha hooks -exports.mochaHooks = { +export const mochaHooks = { beforeEach() { this.logger = stubs.logger }, diff --git a/services/real-time/test/unit/js/AuthorizationManager.test.js b/services/real-time/test/unit/js/AuthorizationManager.test.js new file mode 100644 index 0000000000..407dbaad84 --- /dev/null +++ b/services/real-time/test/unit/js/AuthorizationManager.test.js @@ -0,0 +1,479 @@ +import { beforeEach, describe, chai, expect, it } from 'vitest' +import sinon from 'sinon' + +chai.should() + +const modulePath = '../../../app/js/AuthorizationManager' + +describe('AuthorizationManager', () => { + beforeEach(async ctx => { + ctx.client = { ol_context: {} } + + ctx.AuthorizationManager = (await import(modulePath)).default + }) + + describe('assertClientCanViewProject', () => { + it('should allow the readOnly privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'readOnly' + ctx.AuthorizationManager.assertClientCanViewProject( + ctx.client, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('should allow the readAndWrite privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'readAndWrite' + ctx.AuthorizationManager.assertClientCanViewProject( + ctx.client, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('should allow the review privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'review' + ctx.AuthorizationManager.assertClientCanViewProject( + ctx.client, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('should allow the owner privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'owner' + ctx.AuthorizationManager.assertClientCanViewProject( + ctx.client, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('should return an error with any other privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'unknown' + ctx.AuthorizationManager.assertClientCanViewProject( + ctx.client, + error => { + error.message.should.equal('not authorized') + resolve() + } + ) + }) + }) + }) + + describe('assertClientCanEditProject', () => { + it('should not allow the readOnly privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'readOnly' + ctx.AuthorizationManager.assertClientCanEditProject( + ctx.client, + error => { + error.message.should.equal('not authorized') + resolve() + } + ) + }) + }) + + it('should allow the readAndWrite privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'readAndWrite' + ctx.AuthorizationManager.assertClientCanEditProject( + ctx.client, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('should allow the owner privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'owner' + ctx.AuthorizationManager.assertClientCanEditProject( + ctx.client, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('should return an error with any other privilegeLevel', async ctx => { + await new Promise((resolve, reject) => { + ctx.client.ol_context.privilege_level = 'unknown' + ctx.AuthorizationManager.assertClientCanEditProject( + ctx.client, + error => { + error.message.should.equal('not authorized') + resolve() + } + ) + }) + }) + }) + + // check doc access for project + + describe('assertClientCanViewProjectAndDoc', () => { + beforeEach(ctx => { + ctx.doc_id = '12345' + ctx.callback = sinon.stub() + ctx.client.ol_context = {} + }) + + describe('when not authorised at the project level', () => { + beforeEach(ctx => { + ctx.client.ol_context.privilege_level = 'unknown' + }) + + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + + describe('even when authorised at the doc level', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + + describe('when authorised at the project level', () => { + beforeEach(ctx => { + ctx.client.ol_context.privilege_level = 'readOnly' + }) + + describe('and not authorised at the document level', () => { + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + + describe('and authorised at the document level', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should allow access', ctx => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc( + ctx.client, + ctx.doc_id, + ctx.callback + ) + ctx.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when document authorisation is added and then removed', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + () => { + ctx.AuthorizationManager.removeAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + } + ) + }) + }) + + it('should deny access', ctx => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + }) + + describe('assertClientCanEditProjectAndDoc', () => { + beforeEach(ctx => { + ctx.doc_id = '12345' + ctx.callback = sinon.stub() + ctx.client.ol_context = {} + }) + + describe('when not authorised at the project level', () => { + beforeEach(ctx => { + ctx.client.ol_context.privilege_level = 'readOnly' + }) + + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + + describe('even when authorised at the doc level', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + + describe('when authorised at the project level', () => { + beforeEach(ctx => { + ctx.client.ol_context.privilege_level = 'readAndWrite' + }) + + describe('and not authorised at the document level', () => { + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + + describe('and authorised at the document level', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should allow access', ctx => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc( + ctx.client, + ctx.doc_id, + ctx.callback + ) + ctx.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when document authorisation is added and then removed', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + () => { + ctx.AuthorizationManager.removeAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + } + ) + }) + }) + + it('should deny access', ctx => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + }) + + describe('assertClientCanReviewProjectAndDoc', () => { + beforeEach(ctx => { + ctx.doc_id = '12345' + ctx.callback = sinon.stub() + ctx.client.ol_context = {} + }) + + describe('when not authorised at the project level', () => { + beforeEach(ctx => { + ctx.client.ol_context.privilege_level = 'readOnly' + }) + + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + + describe('even when authorised at the doc level', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + + describe('when authorised at the project level', () => { + beforeEach(ctx => { + ctx.client.ol_context.privilege_level = 'review' + }) + + describe('and not authorised at the document level', () => { + it('should not allow access', ctx => { + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + + describe('and authorised at the document level', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should allow access', ctx => { + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc( + ctx.client, + ctx.doc_id, + ctx.callback + ) + ctx.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when document authorisation is added and then removed', () => { + beforeEach(async ctx => { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.addAccessToDoc( + ctx.client, + ctx.doc_id, + () => { + ctx.AuthorizationManager.removeAccessToDoc( + ctx.client, + ctx.doc_id, + err => { + if (err) return reject(err) + resolve() + } + ) + } + ) + }) + }) + + it('should deny access', ctx => { + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc( + ctx.client, + ctx.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/AuthorizationManagerTests.js b/services/real-time/test/unit/js/AuthorizationManagerTests.js deleted file mode 100644 index a23d814806..0000000000 --- a/services/real-time/test/unit/js/AuthorizationManagerTests.js +++ /dev/null @@ -1,428 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const { expect } = require('chai') -const sinon = require('sinon') -const SandboxedModule = require('sandboxed-module') -const path = require('node:path') -const modulePath = '../../../app/js/AuthorizationManager' - -describe('AuthorizationManager', function () { - beforeEach(function () { - this.client = { ol_context: {} } - - return (this.AuthorizationManager = SandboxedModule.require(modulePath, { - requires: {}, - })) - }) - - describe('assertClientCanViewProject', function () { - it('should allow the readOnly privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'readOnly' - return this.AuthorizationManager.assertClientCanViewProject( - this.client, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - it('should allow the readAndWrite privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'readAndWrite' - return this.AuthorizationManager.assertClientCanViewProject( - this.client, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - it('should allow the review privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'review' - return this.AuthorizationManager.assertClientCanViewProject( - this.client, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - it('should allow the owner privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'owner' - return this.AuthorizationManager.assertClientCanViewProject( - this.client, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - return it('should return an error with any other privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'unknown' - return this.AuthorizationManager.assertClientCanViewProject( - this.client, - error => { - error.message.should.equal('not authorized') - return done() - } - ) - }) - }) - - describe('assertClientCanEditProject', function () { - it('should not allow the readOnly privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'readOnly' - return this.AuthorizationManager.assertClientCanEditProject( - this.client, - error => { - error.message.should.equal('not authorized') - return done() - } - ) - }) - - it('should allow the readAndWrite privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'readAndWrite' - return this.AuthorizationManager.assertClientCanEditProject( - this.client, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - it('should allow the owner privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'owner' - return this.AuthorizationManager.assertClientCanEditProject( - this.client, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - return it('should return an error with any other privilegeLevel', function (done) { - this.client.ol_context.privilege_level = 'unknown' - return this.AuthorizationManager.assertClientCanEditProject( - this.client, - error => { - error.message.should.equal('not authorized') - return done() - } - ) - }) - }) - - // check doc access for project - - describe('assertClientCanViewProjectAndDoc', function () { - beforeEach(function () { - this.doc_id = '12345' - this.callback = sinon.stub() - return (this.client.ol_context = {}) - }) - - describe('when not authorised at the project level', function () { - beforeEach(function () { - return (this.client.ol_context.privilege_level = 'unknown') - }) - - it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanViewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - - return describe('even when authorised at the doc level', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - done - ) - }) - - return it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanViewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - }) - - return describe('when authorised at the project level', function () { - beforeEach(function () { - return (this.client.ol_context.privilege_level = 'readOnly') - }) - - describe('and not authorised at the document level', function () { - return it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanViewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - - describe('and authorised at the document level', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - done - ) - }) - - return it('should allow access', function () { - this.AuthorizationManager.assertClientCanViewProjectAndDoc( - this.client, - this.doc_id, - this.callback - ) - return this.callback.calledWith(null).should.equal(true) - }) - }) - - return describe('when document authorisation is added and then removed', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - () => { - return this.AuthorizationManager.removeAccessToDoc( - this.client, - this.doc_id, - done - ) - } - ) - }) - - return it('should deny access', function () { - return this.AuthorizationManager.assertClientCanViewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - }) - }) - - describe('assertClientCanEditProjectAndDoc', function () { - beforeEach(function () { - this.doc_id = '12345' - this.callback = sinon.stub() - return (this.client.ol_context = {}) - }) - - describe('when not authorised at the project level', function () { - beforeEach(function () { - return (this.client.ol_context.privilege_level = 'readOnly') - }) - - it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanEditProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - - return describe('even when authorised at the doc level', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - done - ) - }) - - return it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanEditProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - }) - - return describe('when authorised at the project level', function () { - beforeEach(function () { - return (this.client.ol_context.privilege_level = 'readAndWrite') - }) - - describe('and not authorised at the document level', function () { - return it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanEditProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - - describe('and authorised at the document level', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - done - ) - }) - - return it('should allow access', function () { - this.AuthorizationManager.assertClientCanEditProjectAndDoc( - this.client, - this.doc_id, - this.callback - ) - return this.callback.calledWith(null).should.equal(true) - }) - }) - - return describe('when document authorisation is added and then removed', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - () => { - return this.AuthorizationManager.removeAccessToDoc( - this.client, - this.doc_id, - done - ) - } - ) - }) - - return it('should deny access', function () { - return this.AuthorizationManager.assertClientCanEditProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - }) - }) - - return describe('assertClientCanReviewProjectAndDoc', function () { - beforeEach(function () { - this.doc_id = '12345' - this.callback = sinon.stub() - return (this.client.ol_context = {}) - }) - - describe('when not authorised at the project level', function () { - beforeEach(function () { - return (this.client.ol_context.privilege_level = 'readOnly') - }) - - it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanReviewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - - return describe('even when authorised at the doc level', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - done - ) - }) - - return it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanReviewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - }) - - return describe('when authorised at the project level', function () { - beforeEach(function () { - return (this.client.ol_context.privilege_level = 'review') - }) - - describe('and not authorised at the document level', function () { - return it('should not allow access', function () { - return this.AuthorizationManager.assertClientCanReviewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - - describe('and authorised at the document level', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - done - ) - }) - - return it('should allow access', function () { - this.AuthorizationManager.assertClientCanReviewProjectAndDoc( - this.client, - this.doc_id, - this.callback - ) - return this.callback.calledWith(null).should.equal(true) - }) - }) - - return describe('when document authorisation is added and then removed', function () { - beforeEach(function (done) { - return this.AuthorizationManager.addAccessToDoc( - this.client, - this.doc_id, - () => { - return this.AuthorizationManager.removeAccessToDoc( - this.client, - this.doc_id, - done - ) - } - ) - }) - - return it('should deny access', function () { - return this.AuthorizationManager.assertClientCanReviewProjectAndDoc( - this.client, - this.doc_id, - err => err.message.should.equal('not authorized') - ) - }) - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/ChannelManager.test.js b/services/real-time/test/unit/js/ChannelManager.test.js new file mode 100644 index 0000000000..eca9269945 --- /dev/null +++ b/services/real-time/test/unit/js/ChannelManager.test.js @@ -0,0 +1,445 @@ +import { vi, expect, describe, beforeEach, it } from 'vitest' + +import sinon from 'sinon' + +const modulePath = '../../../app/js/ChannelManager.js' + +describe('ChannelManager', function () { + beforeEach(async function (ctx) { + ctx.rclient = {} + ctx.other_rclient = {} + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = {}), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.metrics = { + inc: sinon.stub(), + summary: sinon.stub(), + }), + })) + + ctx.ChannelManager = (await import(modulePath)).default + }) + + describe('subscribe', function () { + describe('when there is no existing subscription for this redis client', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.rclient.subscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + }) + }) + + it('should subscribe to the redis channel', function (ctx) { + ctx.rclient.subscribe + .calledWithExactly('applied-ops:1234567890abcdef') + .should.equal(true) + }) + }) + + describe('when there is an existing subscription for this redis client', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.rclient.subscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + }) + }) + + it('should subscribe to the redis channel again', function (ctx) { + ctx.rclient.subscribe.callCount.should.equal(2) + }) + }) + + describe('when subscribe errors', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rclient.subscribe = sinon + .stub() + .onFirstCall() + .rejects(new Error('some redis error')) + .onSecondCall() + .resolves() + const p = ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + p.then(() => + reject(new Error('should not subscribe but fail')) + ).catch(err => { + err.message.should.equal('failed to subscribe to channel') + err.cause.message.should.equal('some redis error') + ctx.ChannelManager.getClientMapEntry(ctx.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + // subscribe is wrapped in Promise, delay other assertions + setTimeout(resolve) + }) + }) + }) + + it('should have recorded the error', function (ctx) { + expect( + ctx.metrics.inc.calledWithExactly('subscribe.failed.applied-ops') + ).to.equal(true) + }) + + it('should subscribe again', function (ctx) { + ctx.rclient.subscribe.callCount.should.equal(2) + }) + + it('should cleanup', function (ctx) { + ctx.ChannelManager.getClientMapEntry(ctx.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + }) + }) + + describe('when subscribe errors and the clientChannelMap entry was replaced', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rclient.subscribe = sinon + .stub() + .onFirstCall() + .rejects(new Error('some redis error')) + .onSecondCall() + .resolves() + ctx.first = ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + // ignore error + ctx.first.catch(() => {}) + expect( + ctx.ChannelManager.getClientMapEntry(ctx.rclient).get( + 'applied-ops:1234567890abcdef' + ) + ).to.equal(ctx.first) + + ctx.rclient.unsubscribe = sinon.stub().resolves() + ctx.ChannelManager.unsubscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + ctx.second = ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + // should get replaced immediately + expect( + ctx.ChannelManager.getClientMapEntry(ctx.rclient).get( + 'applied-ops:1234567890abcdef' + ) + ).to.equal(ctx.second) + + // let the first subscribe error -> unsubscribe -> subscribe + setTimeout(resolve) + }) + }) + + it('should cleanup the second subscribePromise', function (ctx) { + expect( + ctx.ChannelManager.getClientMapEntry(ctx.rclient).has( + 'applied-ops:1234567890abcdef' + ) + ).to.equal(false) + }) + }) + + describe('when there is an existing subscription for another redis client but not this one', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.other_rclient.subscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.other_rclient, + 'applied-ops', + '1234567890abcdef' + ) + ctx.rclient.subscribe = sinon.stub().resolves() // discard the original stub + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + }) + }) + + it('should subscribe to the redis channel on this redis client', function (ctx) { + ctx.rclient.subscribe + .calledWithExactly('applied-ops:1234567890abcdef') + .should.equal(true) + }) + }) + }) + + describe('unsubscribe', function () { + describe('when there is no existing subscription for this redis client', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.rclient.unsubscribe = sinon.stub().resolves() + ctx.ChannelManager.unsubscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + }) + }) + + it('should unsubscribe from the redis channel', function (ctx) { + ctx.rclient.unsubscribe.called.should.equal(true) + }) + }) + + describe('when there is an existing subscription for this another redis client but not this one', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.other_rclient.subscribe = sinon.stub().resolves() + ctx.rclient.unsubscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.other_rclient, + 'applied-ops', + '1234567890abcdef' + ) + ctx.ChannelManager.unsubscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + }) + }) + + it('should still unsubscribe from the redis channel on this client', function (ctx) { + ctx.rclient.unsubscribe.called.should.equal(true) + }) + }) + + describe('when unsubscribe errors and completes', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rclient.subscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + ctx.rclient.unsubscribe = sinon + .stub() + .rejects(new Error('some redis error')) + ctx.ChannelManager.unsubscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + return null + }) + }) + + it('should have cleaned up', function (ctx) { + ctx.ChannelManager.getClientMapEntry(ctx.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + }) + + it('should not error out when subscribing again', async function (ctx) { + await new Promise((resolve, reject) => { + const p = ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + p.then(() => resolve()).catch(reject) + }) + }) + }) + + describe('when unsubscribe errors and another client subscribes at the same time', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rclient.subscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + let rejectSubscribe + ctx.rclient.unsubscribe = () => + new Promise((resolve, reject) => (rejectSubscribe = reject)) + ctx.ChannelManager.unsubscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + + setTimeout(() => { + // delay, actualUnsubscribe should not see the new subscribe request + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + .then(() => setTimeout(resolve)) + .catch(reject) + setTimeout(() => + // delay, rejectSubscribe is not defined immediately + rejectSubscribe(new Error('redis error')) + ) + }) + }) + }) + + it('should have recorded the error', function (ctx) { + expect( + ctx.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops') + ).to.equal(true) + }) + + it('should have subscribed', function (ctx) { + ctx.rclient.subscribe.called.should.equal(true) + }) + + it('should have discarded the finished Promise', function (ctx) { + ctx.ChannelManager.getClientMapEntry(ctx.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + }) + }) + + describe('when there is an existing subscription for this redis client', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rclient.subscribe = sinon.stub().resolves() + ctx.rclient.unsubscribe = sinon.stub().resolves() + ctx.ChannelManager.subscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + ctx.ChannelManager.unsubscribe( + ctx.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(resolve) + }) + }) + + it('should unsubscribe from the redis channel', function (ctx) { + ctx.rclient.unsubscribe + .calledWithExactly('applied-ops:1234567890abcdef') + .should.equal(true) + }) + }) + }) + + describe('publish', function () { + describe("when the channel is 'all'", function () { + beforeEach(function (ctx) { + ctx.rclient.publish = sinon.stub() + ctx.ChannelManager.publish( + ctx.rclient, + 'applied-ops', + 'all', + 'random-message' + ) + }) + + it('should publish on the base channel', function (ctx) { + ctx.rclient.publish + .calledWithExactly('applied-ops', 'random-message') + .should.equal(true) + }) + }) + + describe('when the channel has an specific id', function () { + describe('when the individual channel setting is false', function () { + beforeEach(function (ctx) { + ctx.rclient.publish = sinon.stub() + ctx.settings.publishOnIndividualChannels = false + ctx.ChannelManager.publish( + ctx.rclient, + 'applied-ops', + '1234567890abcdef', + 'random-message' + ) + }) + + it('should publish on the per-id channel', function (ctx) { + ctx.rclient.publish + .calledWithExactly('applied-ops', 'random-message') + .should.equal(true) + ctx.rclient.publish.calledOnce.should.equal(true) + }) + }) + + describe('when the individual channel setting is true', function () { + beforeEach(function (ctx) { + ctx.rclient.publish = sinon.stub() + ctx.settings.publishOnIndividualChannels = true + ctx.ChannelManager.publish( + ctx.rclient, + 'applied-ops', + '1234567890abcdef', + 'random-message' + ) + }) + + it('should publish on the per-id channel', function (ctx) { + ctx.rclient.publish + .calledWithExactly('applied-ops:1234567890abcdef', 'random-message') + .should.equal(true) + ctx.rclient.publish.calledOnce.should.equal(true) + }) + }) + }) + + describe('metrics', function () { + beforeEach(function (ctx) { + ctx.rclient.publish = sinon.stub() + ctx.ChannelManager.publish( + ctx.rclient, + 'applied-ops', + 'all', + 'random-message' + ) + }) + + it('should track the payload size', function (ctx) { + ctx.metrics.summary + .calledWithExactly( + 'redis.publish.applied-ops', + 'random-message'.length + ) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/ChannelManagerTests.js b/services/real-time/test/unit/js/ChannelManagerTests.js deleted file mode 100644 index 2e51c584f2..0000000000 --- a/services/real-time/test/unit/js/ChannelManagerTests.js +++ /dev/null @@ -1,432 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const { expect } = require('chai') -const sinon = require('sinon') -const modulePath = '../../../app/js/ChannelManager.js' -const SandboxedModule = require('sandboxed-module') - -describe('ChannelManager', function () { - beforeEach(function () { - this.rclient = {} - this.other_rclient = {} - return (this.ChannelManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': (this.settings = {}), - '@overleaf/metrics': (this.metrics = { - inc: sinon.stub(), - summary: sinon.stub(), - }), - }, - })) - }) - - describe('subscribe', function () { - describe('when there is no existing subscription for this redis client', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - return setTimeout(done) - }) - - return it('should subscribe to the redis channel', function () { - return this.rclient.subscribe - .calledWithExactly('applied-ops:1234567890abcdef') - .should.equal(true) - }) - }) - - describe('when there is an existing subscription for this redis client', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - return setTimeout(done) - }) - - return it('should subscribe to the redis channel again', function () { - return this.rclient.subscribe.callCount.should.equal(2) - }) - }) - - describe('when subscribe errors', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon - .stub() - .onFirstCall() - .rejects(new Error('some redis error')) - .onSecondCall() - .resolves() - const p = this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - p.then(() => done(new Error('should not subscribe but fail'))).catch( - err => { - err.message.should.equal('failed to subscribe to channel') - err.cause.message.should.equal('some redis error') - this.ChannelManager.getClientMapEntry(this.rclient) - .has('applied-ops:1234567890abcdef') - .should.equal(false) - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - // subscribe is wrapped in Promise, delay other assertions - return setTimeout(done) - } - ) - return null - }) - - it('should have recorded the error', function () { - return expect( - this.metrics.inc.calledWithExactly('subscribe.failed.applied-ops') - ).to.equal(true) - }) - - it('should subscribe again', function () { - return this.rclient.subscribe.callCount.should.equal(2) - }) - - return it('should cleanup', function () { - return this.ChannelManager.getClientMapEntry(this.rclient) - .has('applied-ops:1234567890abcdef') - .should.equal(false) - }) - }) - - describe('when subscribe errors and the clientChannelMap entry was replaced', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon - .stub() - .onFirstCall() - .rejects(new Error('some redis error')) - .onSecondCall() - .resolves() - this.first = this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - // ignore error - this.first.catch(() => {}) - expect( - this.ChannelManager.getClientMapEntry(this.rclient).get( - 'applied-ops:1234567890abcdef' - ) - ).to.equal(this.first) - - this.rclient.unsubscribe = sinon.stub().resolves() - this.ChannelManager.unsubscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - this.second = this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - // should get replaced immediately - expect( - this.ChannelManager.getClientMapEntry(this.rclient).get( - 'applied-ops:1234567890abcdef' - ) - ).to.equal(this.second) - - // let the first subscribe error -> unsubscribe -> subscribe - return setTimeout(done) - }) - - return it('should cleanup the second subscribePromise', function () { - return expect( - this.ChannelManager.getClientMapEntry(this.rclient).has( - 'applied-ops:1234567890abcdef' - ) - ).to.equal(false) - }) - }) - - return describe('when there is an existing subscription for another redis client but not this one', function () { - beforeEach(function (done) { - this.other_rclient.subscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.other_rclient, - 'applied-ops', - '1234567890abcdef' - ) - this.rclient.subscribe = sinon.stub().resolves() // discard the original stub - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - return setTimeout(done) - }) - - return it('should subscribe to the redis channel on this redis client', function () { - return this.rclient.subscribe - .calledWithExactly('applied-ops:1234567890abcdef') - .should.equal(true) - }) - }) - }) - - describe('unsubscribe', function () { - describe('when there is no existing subscription for this redis client', function () { - beforeEach(function (done) { - this.rclient.unsubscribe = sinon.stub().resolves() - this.ChannelManager.unsubscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - return setTimeout(done) - }) - - return it('should unsubscribe from the redis channel', function () { - return this.rclient.unsubscribe.called.should.equal(true) - }) - }) - - describe('when there is an existing subscription for this another redis client but not this one', function () { - beforeEach(function (done) { - this.other_rclient.subscribe = sinon.stub().resolves() - this.rclient.unsubscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.other_rclient, - 'applied-ops', - '1234567890abcdef' - ) - this.ChannelManager.unsubscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - return setTimeout(done) - }) - - return it('should still unsubscribe from the redis channel on this client', function () { - return this.rclient.unsubscribe.called.should.equal(true) - }) - }) - - describe('when unsubscribe errors and completes', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - this.rclient.unsubscribe = sinon - .stub() - .rejects(new Error('some redis error')) - this.ChannelManager.unsubscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - setTimeout(done) - return null - }) - - it('should have cleaned up', function () { - return this.ChannelManager.getClientMapEntry(this.rclient) - .has('applied-ops:1234567890abcdef') - .should.equal(false) - }) - - return it('should not error out when subscribing again', function (done) { - const p = this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - p.then(() => done()).catch(done) - return null - }) - }) - - describe('when unsubscribe errors and another client subscribes at the same time', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - let rejectSubscribe - this.rclient.unsubscribe = () => - new Promise((resolve, reject) => (rejectSubscribe = reject)) - this.ChannelManager.unsubscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - - setTimeout(() => { - // delay, actualUnsubscribe should not see the new subscribe request - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - .then(() => setTimeout(done)) - .catch(done) - return setTimeout(() => - // delay, rejectSubscribe is not defined immediately - rejectSubscribe(new Error('redis error')) - ) - }) - return null - }) - - it('should have recorded the error', function () { - return expect( - this.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops') - ).to.equal(true) - }) - - it('should have subscribed', function () { - return this.rclient.subscribe.called.should.equal(true) - }) - - return it('should have discarded the finished Promise', function () { - return this.ChannelManager.getClientMapEntry(this.rclient) - .has('applied-ops:1234567890abcdef') - .should.equal(false) - }) - }) - - return describe('when there is an existing subscription for this redis client', function () { - beforeEach(function (done) { - this.rclient.subscribe = sinon.stub().resolves() - this.rclient.unsubscribe = sinon.stub().resolves() - this.ChannelManager.subscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - this.ChannelManager.unsubscribe( - this.rclient, - 'applied-ops', - '1234567890abcdef' - ) - return setTimeout(done) - }) - - return it('should unsubscribe from the redis channel', function () { - return this.rclient.unsubscribe - .calledWithExactly('applied-ops:1234567890abcdef') - .should.equal(true) - }) - }) - }) - - return describe('publish', function () { - describe("when the channel is 'all'", function () { - beforeEach(function () { - this.rclient.publish = sinon.stub() - return this.ChannelManager.publish( - this.rclient, - 'applied-ops', - 'all', - 'random-message' - ) - }) - - return it('should publish on the base channel', function () { - return this.rclient.publish - .calledWithExactly('applied-ops', 'random-message') - .should.equal(true) - }) - }) - - describe('when the channel has an specific id', function () { - describe('when the individual channel setting is false', function () { - beforeEach(function () { - this.rclient.publish = sinon.stub() - this.settings.publishOnIndividualChannels = false - return this.ChannelManager.publish( - this.rclient, - 'applied-ops', - '1234567890abcdef', - 'random-message' - ) - }) - - return it('should publish on the per-id channel', function () { - this.rclient.publish - .calledWithExactly('applied-ops', 'random-message') - .should.equal(true) - return this.rclient.publish.calledOnce.should.equal(true) - }) - }) - - return describe('when the individual channel setting is true', function () { - beforeEach(function () { - this.rclient.publish = sinon.stub() - this.settings.publishOnIndividualChannels = true - return this.ChannelManager.publish( - this.rclient, - 'applied-ops', - '1234567890abcdef', - 'random-message' - ) - }) - - return it('should publish on the per-id channel', function () { - this.rclient.publish - .calledWithExactly('applied-ops:1234567890abcdef', 'random-message') - .should.equal(true) - return this.rclient.publish.calledOnce.should.equal(true) - }) - }) - }) - - return describe('metrics', function () { - beforeEach(function () { - this.rclient.publish = sinon.stub() - return this.ChannelManager.publish( - this.rclient, - 'applied-ops', - 'all', - 'random-message' - ) - }) - - return it('should track the payload size', function () { - return this.metrics.summary - .calledWithExactly( - 'redis.publish.applied-ops', - 'random-message'.length - ) - .should.equal(true) - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/ConnectedUsersManager.test.js b/services/real-time/test/unit/js/ConnectedUsersManager.test.js new file mode 100644 index 0000000000..0a252d2c4c --- /dev/null +++ b/services/real-time/test/unit/js/ConnectedUsersManager.test.js @@ -0,0 +1,688 @@ +import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest' +import path from 'node:path' +import sinon from 'sinon' +import tk from 'timekeeper' + +const modulePath = path.join( + import.meta.dirname, + '../../../app/js/ConnectedUsersManager' +) + +describe('ConnectedUsersManager', function () { + beforeEach(async function (ctx) { + tk.freeze(new Date()) + ctx.settings = { + redis: { + realtime: { + key_schema: { + clientsInProject({ project_id: projectId }) { + return `clients_in_project:${projectId}` + }, + connectedUser({ project_id: projectId, client_id: clientId }) { + return `connected_user:${projectId}:${clientId}` + }, + projectNotEmptySince({ projectId }) { + return `projectNotEmptySince:{${projectId}}` + }, + }, + }, + }, + } + ctx.rClient = { + auth() {}, + getdel: sinon.stub(), + scard: sinon.stub(), + set: sinon.stub(), + setex: sinon.stub(), + sadd: sinon.stub(), + get: sinon.stub(), + srem: sinon.stub(), + del: sinon.stub(), + smembers: sinon.stub(), + expire: sinon.stub(), + hset: sinon.stub(), + hgetall: sinon.stub(), + exec: sinon.stub(), + multi: () => { + return ctx.rClient + }, + } + ctx.Metrics = { + inc: sinon.stub(), + histogram: sinon.stub(), + } + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + + vi.doMock('@overleaf/redis-wrapper', () => ({ + default: { + createClient: () => { + return ctx.rClient + }, + }, + })) + + ctx.ConnectedUsersManager = (await import(modulePath)).default + ctx.client_id = '32132132' + ctx.project_id = 'dskjh2u21321' + ctx.user = { + _id: 'user-id-123', + first_name: 'Joe', + last_name: 'Bloggs', + email: 'joe@example.com', + } + ctx.cursorData = { + row: 12, + column: 9, + doc_id: '53c3b8c85fee64000023dc6e', + } + }) + + afterEach(function () { + tk.reset() + }) + + describe('updateUserPosition', function () { + beforeEach(function (ctx) { + ctx.rClient.exec.yields(null, [1, 1]) + }) + + it('should set a key with the date and give it a ttl', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.hset + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 'last_updated_at', + Date.now() + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should set a key with the user_id', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.hset + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 'user_id', + ctx.user._id + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should set a key with the first_name', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.hset + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 'first_name', + ctx.user.first_name + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should set a key with the last_name', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.hset + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 'last_name', + ctx.user.last_name + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should set a key with the email', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.hset + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 'email', + ctx.user.email + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should push the client_id on to the project list', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.sadd + .calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should add a ttl to the project set so it stays clean', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.expire + .calledWith( + `clients_in_project:${ctx.project_id}`, + 24 * 4 * 60 * 60 + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should add a ttl to the connected user so it stays clean', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + null, + err => { + if (err) return reject(err) + ctx.rClient.expire + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 60 * 15 + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should set the cursor position when provided', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + ctx.cursorData, + err => { + if (err) return reject(err) + ctx.rClient.hset + .calledWith( + `connected_user:${ctx.project_id}:${ctx.client_id}`, + 'cursorData', + JSON.stringify(ctx.cursorData) + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + describe('editing_session_mode', function () { + const cases = { + 'should bump the metric when connecting to empty room': { + nConnectedClients: 1, + cursorData: null, + labels: { + method: 'connect', + status: 'single', + }, + }, + 'should bump the metric when connecting to non-empty room': { + nConnectedClients: 2, + cursorData: null, + labels: { + method: 'connect', + status: 'multi', + }, + }, + 'should bump the metric when updating in empty room': { + nConnectedClients: 1, + cursorData: { row: 42 }, + labels: { + method: 'update', + status: 'single', + }, + }, + 'should bump the metric when updating in non-empty room': { + nConnectedClients: 2, + cursorData: { row: 42 }, + labels: { + method: 'update', + status: 'multi', + }, + }, + } + + for (const [ + name, + { nConnectedClients, cursorData, labels }, + ] of Object.entries(cases)) { + it(name, async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.exec.yields(null, [1, nConnectedClients]) + ctx.ConnectedUsersManager.updateUserPosition( + ctx.project_id, + ctx.client_id, + ctx.user, + cursorData, + err => { + if (err) return reject(err) + expect(ctx.Metrics.inc).to.have.been.calledWith( + 'editing_session_mode', + 1, + labels + ) + resolve() + } + ) + }) + }) + } + }) + }) + + describe('markUserAsDisconnected', function () { + beforeEach(function (ctx) { + ctx.rClient.exec.yields(null, [1, 0]) + }) + + it('should remove the user from the set', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + ctx.rClient.srem + .calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should delete the connected_user string', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + ctx.rClient.del + .calledWith(`connected_user:${ctx.project_id}:${ctx.client_id}`) + .should.equal(true) + resolve() + } + ) + }) + }) + + it('should add a ttl to the connected user set so it stays clean', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + ctx.rClient.expire + .calledWith( + `clients_in_project:${ctx.project_id}`, + 24 * 4 * 60 * 60 + ) + .should.equal(true) + resolve() + } + ) + }) + }) + + describe('editing_session_mode', function () { + const cases = { + 'should bump the metric when disconnecting from now empty room': { + nConnectedClients: 0, + labels: { + method: 'disconnect', + status: 'empty', + }, + }, + 'should bump the metric when disconnecting from now single room': { + nConnectedClients: 1, + labels: { + method: 'disconnect', + status: 'single', + }, + }, + 'should bump the metric when disconnecting from now multi room': { + nConnectedClients: 2, + labels: { + method: 'disconnect', + status: 'multi', + }, + }, + } + + for (const [name, { nConnectedClients, labels }] of Object.entries( + cases + )) { + it(name, async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.exec.yields(null, [1, nConnectedClients]) + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + expect(ctx.Metrics.inc).to.have.been.calledWith( + 'editing_session_mode', + 1, + labels + ) + resolve() + } + ) + }) + }) + } + }) + + describe('projectNotEmptySince', function () { + it('should clear the projectNotEmptySince key when empty and skip metric if not set', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.exec.yields(null, [1, 0]) + ctx.rClient.getdel.yields(null, '') + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + expect(ctx.rClient.getdel).to.have.been.calledWith( + `projectNotEmptySince:{${ctx.project_id}}` + ) + expect(ctx.Metrics.histogram).to.not.have.been.called + resolve() + } + ) + }) + }) + it('should clear the projectNotEmptySince key when empty and record metric if set', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.exec.onFirstCall().yields(null, [1, 0]) + tk.freeze(1_234_000) + ctx.rClient.getdel.yields(null, '1230') + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + expect(ctx.rClient.getdel).to.have.been.calledWith( + `projectNotEmptySince:{${ctx.project_id}}` + ) + expect(ctx.Metrics.histogram).to.have.been.calledWith( + 'project_not_empty_since', + 4, + sinon.match.any, + { status: 'empty' } + ) + resolve() + } + ) + }) + }) + it('should set projectNotEmptySince key when single and skip metric if not set before', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.exec.onFirstCall().yields(null, [1, 1]) + tk.freeze(1_233_001) // should ceil up + ctx.rClient.exec.onSecondCall().yields(null, ['']) + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + expect(ctx.rClient.set).to.have.been.calledWith( + `projectNotEmptySince:{${ctx.project_id}}`, + '1234', + 'NX', + 'EX', + 31 * 24 * 60 * 60 + ) + expect(ctx.Metrics.histogram).to.not.have.been.called + resolve() + } + ) + }) + }) + const cases = { + 'should set projectNotEmptySince key when single and record metric if set before': + { + nConnectedClients: 1, + labels: { + status: 'single', + }, + }, + 'should set projectNotEmptySince key when multi and record metric if set before': + { + nConnectedClients: 2, + labels: { + status: 'multi', + }, + }, + } + for (const [name, { nConnectedClients, labels }] of Object.entries( + cases + )) { + it(name, async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients]) + tk.freeze(1_235_000) + ctx.rClient.exec.onSecondCall().yields(null, ['1230']) + ctx.ConnectedUsersManager.markUserAsDisconnected( + ctx.project_id, + ctx.client_id, + err => { + if (err) return reject(err) + expect(ctx.rClient.set).to.have.been.calledWith( + `projectNotEmptySince:{${ctx.project_id}}`, + '1235', + 'NX', + 'EX', + 31 * 24 * 60 * 60 + ) + expect(ctx.Metrics.histogram).to.have.been.calledWith( + 'project_not_empty_since', + 5, + sinon.match.any, + labels + ) + resolve() + } + ) + }) + }) + } + }) + }) + + describe('_getConnectedUser', function () { + it('should return a connected user if there is a user object', async function (ctx) { + await new Promise((resolve, reject) => { + const cursorData = JSON.stringify({ cursorData: { row: 1 } }) + ctx.rClient.hgetall.callsArgWith(1, null, { + connected_at: new Date(), + user_id: ctx.user._id, + last_updated_at: `${Date.now()}`, + cursorData, + }) + ctx.ConnectedUsersManager._getConnectedUser( + ctx.project_id, + ctx.client_id, + (err, result) => { + if (err) return reject(err) + result.connected.should.equal(true) + result.client_id.should.equal(ctx.client_id) + resolve() + } + ) + }) + }) + + it('should return a not connected user if there is no object', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.hgetall.callsArgWith(1, null, null) + ctx.ConnectedUsersManager._getConnectedUser( + ctx.project_id, + ctx.client_id, + (err, result) => { + if (err) return reject(err) + result.connected.should.equal(false) + result.client_id.should.equal(ctx.client_id) + resolve() + } + ) + }) + }) + + it('should return a not connected user if there is an empty object', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.rClient.hgetall.callsArgWith(1, null, {}) + ctx.ConnectedUsersManager._getConnectedUser( + ctx.project_id, + ctx.client_id, + (err, result) => { + if (err) return reject(err) + result.connected.should.equal(false) + result.client_id.should.equal(ctx.client_id) + resolve() + } + ) + }) + }) + }) + + describe('getConnectedUsers', function () { + beforeEach(function (ctx) { + ctx.users = ['1234', '5678', '9123', '8234'] + ctx.rClient.smembers.callsArgWith(1, null, ctx.users) + ctx.ConnectedUsersManager._getConnectedUser = sinon.stub() + ctx.ConnectedUsersManager._getConnectedUser + .withArgs(ctx.project_id, ctx.users[0]) + .callsArgWith(2, null, { + connected: true, + client_age: 2, + client_id: ctx.users[0], + }) + ctx.ConnectedUsersManager._getConnectedUser + .withArgs(ctx.project_id, ctx.users[1]) + .callsArgWith(2, null, { + connected: false, + client_age: 1, + client_id: ctx.users[1], + }) + ctx.ConnectedUsersManager._getConnectedUser + .withArgs(ctx.project_id, ctx.users[2]) + .callsArgWith(2, null, { + connected: true, + client_age: 3, + client_id: ctx.users[2], + }) + ctx.ConnectedUsersManager._getConnectedUser + .withArgs(ctx.project_id, ctx.users[3]) + .callsArgWith(2, null, { + connected: true, + client_age: 11, + client_id: ctx.users[3], + }) + }) // connected but old + + it('should only return the users in the list which are still in redis and recently updated', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.getConnectedUsers( + ctx.project_id, + (err, users) => { + if (err) return reject(err) + users.length.should.equal(2) + users[0].should.deep.equal({ + client_id: ctx.users[0], + client_age: 2, + connected: true, + }) + users[1].should.deep.equal({ + client_id: ctx.users[2], + client_age: 3, + connected: true, + }) + resolve() + } + ) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js deleted file mode 100644 index a6864075e0..0000000000 --- a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js +++ /dev/null @@ -1,648 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ - -const SandboxedModule = require('sandboxed-module') -const assert = require('node:assert') -const path = require('node:path') -const sinon = require('sinon') -const modulePath = path.join(__dirname, '../../../app/js/ConnectedUsersManager') -const { expect } = require('chai') -const tk = require('timekeeper') - -describe('ConnectedUsersManager', function () { - beforeEach(function () { - tk.freeze(new Date()) - this.settings = { - redis: { - realtime: { - key_schema: { - clientsInProject({ project_id: projectId }) { - return `clients_in_project:${projectId}` - }, - connectedUser({ project_id: projectId, client_id: clientId }) { - return `connected_user:${projectId}:${clientId}` - }, - projectNotEmptySince({ projectId }) { - return `projectNotEmptySince:{${projectId}}` - }, - }, - }, - }, - } - this.rClient = { - auth() {}, - getdel: sinon.stub(), - scard: sinon.stub(), - set: sinon.stub(), - setex: sinon.stub(), - sadd: sinon.stub(), - get: sinon.stub(), - srem: sinon.stub(), - del: sinon.stub(), - smembers: sinon.stub(), - expire: sinon.stub(), - hset: sinon.stub(), - hgetall: sinon.stub(), - exec: sinon.stub(), - multi: () => { - return this.rClient - }, - } - this.Metrics = { - inc: sinon.stub(), - histogram: sinon.stub(), - } - - this.ConnectedUsersManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.settings, - '@overleaf/metrics': this.Metrics, - '@overleaf/redis-wrapper': { - createClient: () => { - return this.rClient - }, - }, - }, - }) - this.client_id = '32132132' - this.project_id = 'dskjh2u21321' - this.user = { - _id: 'user-id-123', - first_name: 'Joe', - last_name: 'Bloggs', - email: 'joe@example.com', - } - return (this.cursorData = { - row: 12, - column: 9, - doc_id: '53c3b8c85fee64000023dc6e', - }) - }) - - afterEach(function () { - return tk.reset() - }) - - describe('updateUserPosition', function () { - beforeEach(function () { - this.rClient.exec.yields(null, [1, 1]) - }) - - it('should set a key with the date and give it a ttl', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.hset - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 'last_updated_at', - Date.now() - ) - .should.equal(true) - return done() - } - ) - }) - - it('should set a key with the user_id', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.hset - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 'user_id', - this.user._id - ) - .should.equal(true) - return done() - } - ) - }) - - it('should set a key with the first_name', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.hset - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 'first_name', - this.user.first_name - ) - .should.equal(true) - return done() - } - ) - }) - - it('should set a key with the last_name', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.hset - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 'last_name', - this.user.last_name - ) - .should.equal(true) - return done() - } - ) - }) - - it('should set a key with the email', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.hset - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 'email', - this.user.email - ) - .should.equal(true) - return done() - } - ) - }) - - it('should push the client_id on to the project list', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.sadd - .calledWith(`clients_in_project:${this.project_id}`, this.client_id) - .should.equal(true) - return done() - } - ) - }) - - it('should add a ttl to the project set so it stays clean', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.expire - .calledWith( - `clients_in_project:${this.project_id}`, - 24 * 4 * 60 * 60 - ) - .should.equal(true) - return done() - } - ) - }) - - it('should add a ttl to the connected user so it stays clean', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - null, - err => { - if (err) return done(err) - this.rClient.expire - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 60 * 15 - ) - .should.equal(true) - return done() - } - ) - }) - - it('should set the cursor position when provided', function (done) { - return this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - this.cursorData, - err => { - if (err) return done(err) - this.rClient.hset - .calledWith( - `connected_user:${this.project_id}:${this.client_id}`, - 'cursorData', - JSON.stringify(this.cursorData) - ) - .should.equal(true) - return done() - } - ) - }) - - describe('editing_session_mode', function () { - const cases = { - 'should bump the metric when connecting to empty room': { - nConnectedClients: 1, - cursorData: null, - labels: { - method: 'connect', - status: 'single', - }, - }, - 'should bump the metric when connecting to non-empty room': { - nConnectedClients: 2, - cursorData: null, - labels: { - method: 'connect', - status: 'multi', - }, - }, - 'should bump the metric when updating in empty room': { - nConnectedClients: 1, - cursorData: { row: 42 }, - labels: { - method: 'update', - status: 'single', - }, - }, - 'should bump the metric when updating in non-empty room': { - nConnectedClients: 2, - cursorData: { row: 42 }, - labels: { - method: 'update', - status: 'multi', - }, - }, - } - - for (const [ - name, - { nConnectedClients, cursorData, labels }, - ] of Object.entries(cases)) { - it(name, function (done) { - this.rClient.exec.yields(null, [1, nConnectedClients]) - this.ConnectedUsersManager.updateUserPosition( - this.project_id, - this.client_id, - this.user, - cursorData, - err => { - if (err) return done(err) - expect(this.Metrics.inc).to.have.been.calledWith( - 'editing_session_mode', - 1, - labels - ) - done() - } - ) - }) - } - }) - }) - - describe('markUserAsDisconnected', function () { - beforeEach(function () { - this.rClient.exec.yields(null, [1, 0]) - }) - - it('should remove the user from the set', function (done) { - return this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - this.rClient.srem - .calledWith(`clients_in_project:${this.project_id}`, this.client_id) - .should.equal(true) - return done() - } - ) - }) - - it('should delete the connected_user string', function (done) { - return this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - this.rClient.del - .calledWith(`connected_user:${this.project_id}:${this.client_id}`) - .should.equal(true) - return done() - } - ) - }) - - it('should add a ttl to the connected user set so it stays clean', function (done) { - return this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - this.rClient.expire - .calledWith( - `clients_in_project:${this.project_id}`, - 24 * 4 * 60 * 60 - ) - .should.equal(true) - return done() - } - ) - }) - - describe('editing_session_mode', function () { - const cases = { - 'should bump the metric when disconnecting from now empty room': { - nConnectedClients: 0, - labels: { - method: 'disconnect', - status: 'empty', - }, - }, - 'should bump the metric when disconnecting from now single room': { - nConnectedClients: 1, - labels: { - method: 'disconnect', - status: 'single', - }, - }, - 'should bump the metric when disconnecting from now multi room': { - nConnectedClients: 2, - labels: { - method: 'disconnect', - status: 'multi', - }, - }, - } - - for (const [name, { nConnectedClients, labels }] of Object.entries( - cases - )) { - it(name, function (done) { - this.rClient.exec.yields(null, [1, nConnectedClients]) - this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - expect(this.Metrics.inc).to.have.been.calledWith( - 'editing_session_mode', - 1, - labels - ) - done() - } - ) - }) - } - }) - - describe('projectNotEmptySince', function () { - it('should clear the projectNotEmptySince key when empty and skip metric if not set', function (done) { - this.rClient.exec.yields(null, [1, 0]) - this.rClient.getdel.yields(null, '') - this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - expect(this.rClient.getdel).to.have.been.calledWith( - `projectNotEmptySince:{${this.project_id}}` - ) - expect(this.Metrics.histogram).to.not.have.been.called - done() - } - ) - }) - it('should clear the projectNotEmptySince key when empty and record metric if set', function (done) { - this.rClient.exec.onFirstCall().yields(null, [1, 0]) - tk.freeze(1_234_000) - this.rClient.getdel.yields(null, '1230') - this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - expect(this.rClient.getdel).to.have.been.calledWith( - `projectNotEmptySince:{${this.project_id}}` - ) - expect(this.Metrics.histogram).to.have.been.calledWith( - 'project_not_empty_since', - 4, - sinon.match.any, - { status: 'empty' } - ) - done() - } - ) - }) - it('should set projectNotEmptySince key when single and skip metric if not set before', function (done) { - this.rClient.exec.onFirstCall().yields(null, [1, 1]) - tk.freeze(1_233_001) // should ceil up - this.rClient.exec.onSecondCall().yields(null, ['']) - this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - expect(this.rClient.set).to.have.been.calledWith( - `projectNotEmptySince:{${this.project_id}}`, - '1234', - 'NX', - 'EX', - 31 * 24 * 60 * 60 - ) - expect(this.Metrics.histogram).to.not.have.been.called - done() - } - ) - }) - const cases = { - 'should set projectNotEmptySince key when single and record metric if set before': - { - nConnectedClients: 1, - labels: { - status: 'single', - }, - }, - 'should set projectNotEmptySince key when multi and record metric if set before': - { - nConnectedClients: 2, - labels: { - status: 'multi', - }, - }, - } - for (const [name, { nConnectedClients, labels }] of Object.entries( - cases - )) { - it(name, function (done) { - this.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients]) - tk.freeze(1_235_000) - this.rClient.exec.onSecondCall().yields(null, ['1230']) - this.ConnectedUsersManager.markUserAsDisconnected( - this.project_id, - this.client_id, - err => { - if (err) return done(err) - expect(this.rClient.set).to.have.been.calledWith( - `projectNotEmptySince:{${this.project_id}}`, - '1235', - 'NX', - 'EX', - 31 * 24 * 60 * 60 - ) - expect(this.Metrics.histogram).to.have.been.calledWith( - 'project_not_empty_since', - 5, - sinon.match.any, - labels - ) - done() - } - ) - }) - } - }) - }) - - describe('_getConnectedUser', function () { - it('should return a connected user if there is a user object', function (done) { - const cursorData = JSON.stringify({ cursorData: { row: 1 } }) - this.rClient.hgetall.callsArgWith(1, null, { - connected_at: new Date(), - user_id: this.user._id, - last_updated_at: `${Date.now()}`, - cursorData, - }) - return this.ConnectedUsersManager._getConnectedUser( - this.project_id, - this.client_id, - (err, result) => { - if (err) return done(err) - result.connected.should.equal(true) - result.client_id.should.equal(this.client_id) - return done() - } - ) - }) - - it('should return a not connected user if there is no object', function (done) { - this.rClient.hgetall.callsArgWith(1, null, null) - return this.ConnectedUsersManager._getConnectedUser( - this.project_id, - this.client_id, - (err, result) => { - if (err) return done(err) - result.connected.should.equal(false) - result.client_id.should.equal(this.client_id) - return done() - } - ) - }) - - return it('should return a not connected user if there is an empty object', function (done) { - this.rClient.hgetall.callsArgWith(1, null, {}) - return this.ConnectedUsersManager._getConnectedUser( - this.project_id, - this.client_id, - (err, result) => { - if (err) return done(err) - result.connected.should.equal(false) - result.client_id.should.equal(this.client_id) - return done() - } - ) - }) - }) - - return describe('getConnectedUsers', function () { - beforeEach(function () { - this.users = ['1234', '5678', '9123', '8234'] - this.rClient.smembers.callsArgWith(1, null, this.users) - this.ConnectedUsersManager._getConnectedUser = sinon.stub() - this.ConnectedUsersManager._getConnectedUser - .withArgs(this.project_id, this.users[0]) - .callsArgWith(2, null, { - connected: true, - client_age: 2, - client_id: this.users[0], - }) - this.ConnectedUsersManager._getConnectedUser - .withArgs(this.project_id, this.users[1]) - .callsArgWith(2, null, { - connected: false, - client_age: 1, - client_id: this.users[1], - }) - this.ConnectedUsersManager._getConnectedUser - .withArgs(this.project_id, this.users[2]) - .callsArgWith(2, null, { - connected: true, - client_age: 3, - client_id: this.users[2], - }) - return this.ConnectedUsersManager._getConnectedUser - .withArgs(this.project_id, this.users[3]) - .callsArgWith(2, null, { - connected: true, - client_age: 11, - client_id: this.users[3], - }) - }) // connected but old - - return it('should only return the users in the list which are still in redis and recently updated', function (done) { - return this.ConnectedUsersManager.getConnectedUsers( - this.project_id, - (err, users) => { - if (err) return done(err) - users.length.should.equal(2) - users[0].should.deep.equal({ - client_id: this.users[0], - client_age: 2, - connected: true, - }) - users[1].should.deep.equal({ - client_id: this.users[2], - client_age: 3, - connected: true, - }) - return done() - } - ) - }) - }) -}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterController.test.js b/services/real-time/test/unit/js/DocumentUpdaterController.test.js new file mode 100644 index 0000000000..bd2ad4cfb8 --- /dev/null +++ b/services/real-time/test/unit/js/DocumentUpdaterController.test.js @@ -0,0 +1,271 @@ +import { vi, describe, beforeEach, it } from 'vitest' + +import sinon from 'sinon' +import MockClient from './helpers/MockClient.js' +import path from 'node:path' + +const modulePath = path.join( + import.meta.dirname, + '../../../app/js/DocumentUpdaterController' +) + +describe('DocumentUpdaterController', function () { + beforeEach(async function (ctx) { + ctx.project_id = 'project-id-123' + ctx.doc_id = 'doc-id-123' + ctx.callback = sinon.stub() + ctx.io = { mock: 'socket.io' } + ctx.rclient = [] + ctx.RoomEvents = { on: sinon.stub() } + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + redis: { + documentupdater: { + key_schema: { + pendingUpdates({ doc_id: docId }) { + return `PendingUpdates:${docId}` + }, + }, + }, + pubsub: null, + }, + }), + })) + + vi.doMock('../../../app/js/RedisClientManager', () => ({ + default: { + createClientList: () => { + ctx.redis = { + createClient: name => { + let rclientStub + ctx.rclient.push((rclientStub = { name })) + return rclientStub + }, + } + }, + }, + })) + + vi.doMock('../../../app/js/SafeJsonParse', () => ({ + default: (ctx.SafeJsonParse = { + parse: (data, cb) => cb(null, JSON.parse(data)), + }), + })) + + vi.doMock('../../../app/js/EventLogger', () => ({ + default: (ctx.EventLogger = { checkEventOrder: sinon.stub() }), + })) + + vi.doMock('../../../app/js/HealthCheckManager', () => ({ + default: { check: sinon.stub() }, + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.metrics = { + inc: sinon.stub(), + histogram: sinon.stub(), + }), + })) + + vi.doMock('../../../app/js/RoomManager', () => ({ + default: (ctx.RoomManager = { + eventSource: sinon.stub().returns(ctx.RoomEvents), + }), + })) + + vi.doMock('../../../app/js/ChannelManager', () => ({ + default: (ctx.ChannelManager = {}), + })) + + ctx.EditorUpdatesController = (await import(modulePath)).default + }) + + describe('listenForUpdatesFromDocumentUpdater', function () { + beforeEach(function (ctx) { + ctx.rclient.length = 0 // clear any existing clients + ctx.EditorUpdatesController.rclientList = [ + ctx.redis.createClient('first'), + ctx.redis.createClient('second'), + ] + ctx.rclient[0].subscribe = sinon.stub() + ctx.rclient[0].on = sinon.stub() + ctx.rclient[1].subscribe = sinon.stub() + ctx.rclient[1].on = sinon.stub() + ctx.EditorUpdatesController.listenForUpdatesFromDocumentUpdater() + }) + + it('should subscribe to the doc-updater stream', function (ctx) { + ctx.rclient[0].subscribe.calledWith('applied-ops').should.equal(true) + }) + + it('should register a callback to handle updates', function (ctx) { + ctx.rclient[0].on.calledWith('message').should.equal(true) + }) + + it('should subscribe to any additional doc-updater stream', function (ctx) { + ctx.rclient[1].subscribe.calledWith('applied-ops').should.equal(true) + ctx.rclient[1].on.calledWith('message').should.equal(true) + }) + }) + + describe('_processMessageFromDocumentUpdater', function () { + describe('with bad JSON', function () { + beforeEach(function (ctx) { + ctx.SafeJsonParse.parse = sinon + .stub() + .callsArgWith(1, new Error('oops')) + ctx.EditorUpdatesController._processMessageFromDocumentUpdater( + ctx.io, + 'applied-ops', + 'blah' + ) + }) + + it('should log an error', function (ctx) { + ctx.logger.error.called.should.equal(true) + }) + }) + + describe('with update', function () { + beforeEach(function (ctx) { + ctx.message = { + doc_id: ctx.doc_id, + op: { t: 'foo', p: 12 }, + } + ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater = + sinon.stub() + ctx.EditorUpdatesController._processMessageFromDocumentUpdater( + ctx.io, + 'applied-ops', + JSON.stringify(ctx.message) + ) + }) + + it('should apply the update', function (ctx) { + ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater + .calledWith(ctx.io, ctx.doc_id, ctx.message.op) + .should.equal(true) + }) + }) + + describe('with error', function () { + beforeEach(function (ctx) { + ctx.message = { + doc_id: ctx.doc_id, + error: 'Something went wrong', + } + ctx.EditorUpdatesController._processErrorFromDocumentUpdater = + sinon.stub() + ctx.EditorUpdatesController._processMessageFromDocumentUpdater( + ctx.io, + 'applied-ops', + JSON.stringify(ctx.message) + ) + }) + + it('should process the error', function (ctx) { + ctx.EditorUpdatesController._processErrorFromDocumentUpdater + .calledWith(ctx.io, ctx.doc_id, ctx.message.error) + .should.equal(true) + }) + }) + }) + + describe('_applyUpdateFromDocumentUpdater', function () { + beforeEach(function (ctx) { + ctx.sourceClient = new MockClient() + ctx.otherClients = [new MockClient(), new MockClient()] + ctx.update = { + op: [{ t: 'foo', p: 12 }], + meta: { source: ctx.sourceClient.publicId }, + v: (ctx.version = 42), + doc: ctx.doc_id, + } + ctx.io.sockets = { + clients: sinon + .stub() + .returns([ + ctx.sourceClient, + ...Array.from(ctx.otherClients), + ctx.sourceClient, + ]), + } + }) // include a duplicate client + + describe('normally', function () { + beforeEach(function (ctx) { + ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater( + ctx.io, + ctx.doc_id, + ctx.update + ) + }) + + it('should send a version bump to the source client', function (ctx) { + ctx.sourceClient.emit + .calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id }) + .should.equal(true) + ctx.sourceClient.emit.calledOnce.should.equal(true) + }) + + it('should get the clients connected to the document', function (ctx) { + ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true) + }) + + it('should send the full update to the other clients', function (ctx) { + Array.from(ctx.otherClients).map(client => + client.emit + .calledWith('otUpdateApplied', ctx.update) + .should.equal(true) + ) + }) + }) + + describe('with a duplicate op', function () { + beforeEach(function (ctx) { + ctx.update.dup = true + ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater( + ctx.io, + ctx.doc_id, + ctx.update + ) + }) + + it('should send a version bump to the source client as usual', function (ctx) { + ctx.sourceClient.emit + .calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id }) + .should.equal(true) + }) + + it("should not send anything to the other clients (they've already had the op)", function (ctx) { + Array.from(ctx.otherClients).map(client => + client.emit.calledWith('otUpdateApplied').should.equal(false) + ) + }) + }) + }) + + describe('_processErrorFromDocumentUpdater', function () { + beforeEach(function (ctx) { + ctx.clients = [new MockClient(), new MockClient()] + ctx.io.sockets = { clients: sinon.stub().returns(ctx.clients) } + ctx.EditorUpdatesController._processErrorFromDocumentUpdater( + ctx.io, + ctx.doc_id, + 'Something went wrong' + ) + }) + + it('should log a warning', function (ctx) { + ctx.logger.warn.called.should.equal(true) + }) + + it('should disconnect all clients in that document', function (ctx) { + ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true) + Array.from(ctx.clients).map(client => + client.disconnect.called.should.equal(true) + ) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js b/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js deleted file mode 100644 index dd34c62715..0000000000 --- a/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js +++ /dev/null @@ -1,259 +0,0 @@ -/* eslint-disable - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const modulePath = require('node:path').join( - __dirname, - '../../../app/js/DocumentUpdaterController' -) -const MockClient = require('./helpers/MockClient') - -describe('DocumentUpdaterController', function () { - beforeEach(function () { - this.project_id = 'project-id-123' - this.doc_id = 'doc-id-123' - this.callback = sinon.stub() - this.io = { mock: 'socket.io' } - this.rclient = [] - this.RoomEvents = { on: sinon.stub() } - this.EditorUpdatesController = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': (this.settings = { - redis: { - documentupdater: { - key_schema: { - pendingUpdates({ doc_id: docId }) { - return `PendingUpdates:${docId}` - }, - }, - }, - pubsub: null, - }, - }), - './RedisClientManager': { - createClientList: () => { - this.redis = { - createClient: name => { - let rclientStub - this.rclient.push((rclientStub = { name })) - return rclientStub - }, - } - }, - }, - './SafeJsonParse': (this.SafeJsonParse = { - parse: (data, cb) => cb(null, JSON.parse(data)), - }), - './EventLogger': (this.EventLogger = { checkEventOrder: sinon.stub() }), - './HealthCheckManager': { check: sinon.stub() }, - '@overleaf/metrics': (this.metrics = { - inc: sinon.stub(), - histogram: sinon.stub(), - }), - './RoomManager': (this.RoomManager = { - eventSource: sinon.stub().returns(this.RoomEvents), - }), - './ChannelManager': (this.ChannelManager = {}), - }, - }) - }) - - describe('listenForUpdatesFromDocumentUpdater', function () { - beforeEach(function () { - this.rclient.length = 0 // clear any existing clients - this.EditorUpdatesController.rclientList = [ - this.redis.createClient('first'), - this.redis.createClient('second'), - ] - this.rclient[0].subscribe = sinon.stub() - this.rclient[0].on = sinon.stub() - this.rclient[1].subscribe = sinon.stub() - this.rclient[1].on = sinon.stub() - this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater() - }) - - it('should subscribe to the doc-updater stream', function () { - this.rclient[0].subscribe.calledWith('applied-ops').should.equal(true) - }) - - it('should register a callback to handle updates', function () { - this.rclient[0].on.calledWith('message').should.equal(true) - }) - - it('should subscribe to any additional doc-updater stream', function () { - this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true) - this.rclient[1].on.calledWith('message').should.equal(true) - }) - }) - - describe('_processMessageFromDocumentUpdater', function () { - describe('with bad JSON', function () { - beforeEach(function () { - this.SafeJsonParse.parse = sinon - .stub() - .callsArgWith(1, new Error('oops')) - return this.EditorUpdatesController._processMessageFromDocumentUpdater( - this.io, - 'applied-ops', - 'blah' - ) - }) - - it('should log an error', function () { - return this.logger.error.called.should.equal(true) - }) - }) - - describe('with update', function () { - beforeEach(function () { - this.message = { - doc_id: this.doc_id, - op: { t: 'foo', p: 12 }, - } - this.EditorUpdatesController._applyUpdateFromDocumentUpdater = - sinon.stub() - return this.EditorUpdatesController._processMessageFromDocumentUpdater( - this.io, - 'applied-ops', - JSON.stringify(this.message) - ) - }) - - it('should apply the update', function () { - return this.EditorUpdatesController._applyUpdateFromDocumentUpdater - .calledWith(this.io, this.doc_id, this.message.op) - .should.equal(true) - }) - }) - - describe('with error', function () { - beforeEach(function () { - this.message = { - doc_id: this.doc_id, - error: 'Something went wrong', - } - this.EditorUpdatesController._processErrorFromDocumentUpdater = - sinon.stub() - return this.EditorUpdatesController._processMessageFromDocumentUpdater( - this.io, - 'applied-ops', - JSON.stringify(this.message) - ) - }) - - return it('should process the error', function () { - return this.EditorUpdatesController._processErrorFromDocumentUpdater - .calledWith(this.io, this.doc_id, this.message.error) - .should.equal(true) - }) - }) - }) - - describe('_applyUpdateFromDocumentUpdater', function () { - beforeEach(function () { - this.sourceClient = new MockClient() - this.otherClients = [new MockClient(), new MockClient()] - this.update = { - op: [{ t: 'foo', p: 12 }], - meta: { source: this.sourceClient.publicId }, - v: (this.version = 42), - doc: this.doc_id, - } - return (this.io.sockets = { - clients: sinon - .stub() - .returns([ - this.sourceClient, - ...Array.from(this.otherClients), - this.sourceClient, - ]), - }) - }) // include a duplicate client - - describe('normally', function () { - beforeEach(function () { - return this.EditorUpdatesController._applyUpdateFromDocumentUpdater( - this.io, - this.doc_id, - this.update - ) - }) - - it('should send a version bump to the source client', function () { - this.sourceClient.emit - .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id }) - .should.equal(true) - return this.sourceClient.emit.calledOnce.should.equal(true) - }) - - it('should get the clients connected to the document', function () { - return this.io.sockets.clients - .calledWith(this.doc_id) - .should.equal(true) - }) - - return it('should send the full update to the other clients', function () { - return Array.from(this.otherClients).map(client => - client.emit - .calledWith('otUpdateApplied', this.update) - .should.equal(true) - ) - }) - }) - - return describe('with a duplicate op', function () { - beforeEach(function () { - this.update.dup = true - return this.EditorUpdatesController._applyUpdateFromDocumentUpdater( - this.io, - this.doc_id, - this.update - ) - }) - - it('should send a version bump to the source client as usual', function () { - return this.sourceClient.emit - .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id }) - .should.equal(true) - }) - - return it("should not send anything to the other clients (they've already had the op)", function () { - return Array.from(this.otherClients).map(client => - client.emit.calledWith('otUpdateApplied').should.equal(false) - ) - }) - }) - }) - - return describe('_processErrorFromDocumentUpdater', function () { - beforeEach(function () { - this.clients = [new MockClient(), new MockClient()] - this.io.sockets = { clients: sinon.stub().returns(this.clients) } - return this.EditorUpdatesController._processErrorFromDocumentUpdater( - this.io, - this.doc_id, - 'Something went wrong' - ) - }) - - it('should log a warning', function () { - return this.logger.warn.called.should.equal(true) - }) - - return it('should disconnect all clients in that document', function () { - this.io.sockets.clients.calledWith(this.doc_id).should.equal(true) - return Array.from(this.clients).map(client => - client.disconnect.called.should.equal(true) - ) - }) - }) -}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterManager.test.js b/services/real-time/test/unit/js/DocumentUpdaterManager.test.js new file mode 100644 index 0000000000..7d28d4eb06 --- /dev/null +++ b/services/real-time/test/unit/js/DocumentUpdaterManager.test.js @@ -0,0 +1,422 @@ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { vi, describe, beforeEach, it, afterEach } from 'vitest' +import _ from 'lodash' +const modulePath = '../../../app/js/DocumentUpdaterManager' + +describe('DocumentUpdaterManager', function () { + beforeEach(async function (ctx) { + ctx.project_id = 'project-id-923' + ctx.doc_id = 'doc-id-394' + ctx.lines = ['one', 'two', 'three'] + ctx.version = 42 + ctx.settings = { + apis: { documentupdater: { url: 'http://doc-updater.example.com' } }, + redis: { + documentupdater: { + key_schema: { + pendingUpdates({ doc_id: docId }) { + return `PendingUpdates:${docId}` + }, + }, + }, + }, + maxUpdateSize: 7 * 1024 * 1024, + pendingUpdateListShardCount: 10, + } + ctx.rclient = { auth() {} } + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('request', () => ({ + default: (ctx.request = {}), + })) + + vi.doMock('@overleaf/redis-wrapper', () => ({ + default: { createClient: () => ctx.rclient }, + })) + + class Timer { + done() {} + } + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.Metrics = { + summary: sinon.stub(), + Timer, + }), + })) + + ctx.DocumentUpdaterManager = (await import(modulePath)).default + }) // avoid modifying JSON object directly + + describe('getDocument', function () { + beforeEach(function (ctx) { + ctx.callback = sinon.stub() + }) + + describe('successfully', function () { + beforeEach(function (ctx) { + ctx.body = JSON.stringify({ + lines: ctx.lines, + version: ctx.version, + ops: (ctx.ops = ['mock-op-1', 'mock-op-2']), + ranges: (ctx.ranges = { mock: 'ranges' }), + }) + ctx.fromVersion = 2 + ctx.request.get = sinon + .stub() + .callsArgWith(1, null, { statusCode: 200 }, ctx.body) + ctx.DocumentUpdaterManager.getDocument( + ctx.project_id, + ctx.doc_id, + ctx.fromVersion, + ctx.callback + ) + }) + + it('should get the document from the document updater', function (ctx) { + const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}/doc/${ctx.doc_id}?fromVersion=${ctx.fromVersion}&historyOTSupport=true` + ctx.request.get.calledWith(url).should.equal(true) + }) + + it('should call the callback with the lines, version, ranges and ops', function (ctx) { + ctx.callback + .calledWith(null, ctx.lines, ctx.version, ctx.ranges, ctx.ops) + .should.equal(true) + }) + }) + + describe('when the document updater API returns an error', function () { + beforeEach(function (ctx) { + ctx.request.get = sinon + .stub() + .callsArgWith( + 1, + (ctx.error = new Error('something went wrong')), + null, + null + ) + ctx.DocumentUpdaterManager.getDocument( + ctx.project_id, + ctx.doc_id, + ctx.fromVersion, + ctx.callback + ) + }) + + it('should return an error to the callback', function (ctx) { + ctx.callback.calledWith(ctx.error).should.equal(true) + }) + }) + ;[404, 422].forEach(statusCode => + describe(`when the document updater returns a ${statusCode} status code`, function () { + beforeEach(function (ctx) { + ctx.request.get = sinon + .stub() + .callsArgWith(1, null, { statusCode }, '') + ctx.DocumentUpdaterManager.getDocument( + ctx.project_id, + ctx.doc_id, + ctx.fromVersion, + ctx.callback + ) + }) + + it('should return the callback with an error', function (ctx) { + ctx.callback.called.should.equal(true) + ctx.callback + .calledWith( + sinon.match({ + message: 'doc updater could not load requested ops', + info: { statusCode }, + }) + ) + .should.equal(true) + ctx.logger.error.called.should.equal(false) + ctx.logger.warn.called.should.equal(false) + }) + }) + ) + + describe('when the document updater returns a failure error code', function () { + beforeEach(function (ctx) { + ctx.request.get = sinon + .stub() + .callsArgWith(1, null, { statusCode: 500 }, '') + ctx.DocumentUpdaterManager.getDocument( + ctx.project_id, + ctx.doc_id, + ctx.fromVersion, + ctx.callback + ) + }) + + it('should return the callback with an error', function (ctx) { + ctx.callback.called.should.equal(true) + ctx.callback + .calledWith( + sinon.match({ + message: 'doc updater returned a non-success status code', + info: { + action: 'getDocument', + statusCode: 500, + }, + }) + ) + .should.equal(true) + ctx.logger.error.called.should.equal(false) + }) + }) + }) + + describe('flushProjectToMongoAndDelete', function () { + beforeEach(function (ctx) { + ctx.callback = sinon.stub() + }) + + describe('successfully', function () { + beforeEach(function (ctx) { + ctx.request.del = sinon + .stub() + .callsArgWith(1, null, { statusCode: 204 }, '') + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete( + ctx.project_id, + ctx.callback + ) + }) + + it('should delete the project from the document updater', function (ctx) { + const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}?background=true` + ctx.request.del.calledWith(url).should.equal(true) + }) + + it('should call the callback with no error', function (ctx) { + ctx.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when the document updater API returns an error', function () { + beforeEach(function (ctx) { + ctx.request.del = sinon + .stub() + .callsArgWith( + 1, + (ctx.error = new Error('something went wrong')), + null, + null + ) + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete( + ctx.project_id, + ctx.callback + ) + }) + + it('should return an error to the callback', function (ctx) { + ctx.callback.calledWith(ctx.error).should.equal(true) + }) + }) + + describe('when the document updater returns a failure error code', function () { + beforeEach(function (ctx) { + ctx.request.del = sinon + .stub() + .callsArgWith(1, null, { statusCode: 500 }, '') + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete( + ctx.project_id, + ctx.callback + ) + }) + + it('should return the callback with an error', function (ctx) { + ctx.callback.called.should.equal(true) + ctx.callback + .calledWith( + sinon.match({ + message: 'doc updater returned a non-success status code', + info: { + action: 'flushProjectToMongoAndDelete', + statusCode: 500, + }, + }) + ) + .should.equal(true) + }) + }) + }) + + describe('queueChange', function () { + beforeEach(function (ctx) { + ctx.change = { + doc: '1234567890', + op: [{ d: 'test', p: 345 }], + v: 789, + } + ctx.rclient.rpush = sinon.stub().yields() + ctx.callback = sinon.stub() + }) + + describe('successfully', function () { + beforeEach(function (ctx) { + ctx.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}` + + ctx.DocumentUpdaterManager._getPendingUpdateListKey = sinon + .stub() + .returns(ctx.pendingUpdateListKey) + ctx.DocumentUpdaterManager.queueChange( + ctx.project_id, + ctx.doc_id, + ctx.change, + ctx.callback + ) + }) + + it('should push the change', function (ctx) { + ctx.rclient.rpush + .calledWith( + `PendingUpdates:${ctx.doc_id}`, + JSON.stringify(ctx.change) + ) + .should.equal(true) + }) + + it('should notify the doc updater of the change via the pending-updates-list queue', function (ctx) { + ctx.rclient.rpush + .calledWith( + ctx.pendingUpdateListKey, + `${ctx.project_id}:${ctx.doc_id}` + ) + .should.equal(true) + }) + }) + + describe('with error talking to redis during rpush', function () { + beforeEach(function (ctx) { + ctx.rclient.rpush = sinon + .stub() + .yields(new Error('something went wrong')) + ctx.DocumentUpdaterManager.queueChange( + ctx.project_id, + ctx.doc_id, + ctx.change, + ctx.callback + ) + }) + + it('should return an error', function (ctx) { + ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true) + }) + }) + + describe('with null byte corruption', function () { + beforeEach(function (ctx) { + ctx.stringifyStub = sinon + .stub(JSON, 'stringify') + .callsFake(() => '["bad bytes! \u0000 <- here"]') + ctx.DocumentUpdaterManager.queueChange( + ctx.project_id, + ctx.doc_id, + ctx.change, + ctx.callback + ) + }) + + afterEach(function (ctx) { + ctx.stringifyStub.restore() + }) + + it('should return an error', function (ctx) { + ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true) + }) + + it('should not push the change onto the pending-updates-list queue', function (ctx) { + ctx.rclient.rpush.called.should.equal(false) + }) + }) + + describe('when the update is too large', function () { + beforeEach(function (ctx) { + ctx.change = { + op: { p: 12, t: 'update is too large'.repeat(1024 * 400) }, + } + ctx.DocumentUpdaterManager.queueChange( + ctx.project_id, + ctx.doc_id, + ctx.change, + ctx.callback + ) + }) + + it('should return an error', function (ctx) { + ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true) + }) + + it('should add the size to the error', function (ctx) { + ctx.callback.args[0][0].info.updateSize.should.equal(7782422) + }) + + it('should not push the change onto the pending-updates-list queue', function (ctx) { + ctx.rclient.rpush.called.should.equal(false) + }) + }) + + describe('with invalid keys', function () { + beforeEach(function (ctx) { + ctx.change = { + op: [{ d: 'test', p: 345 }], + version: 789, // not a valid key + } + ctx.DocumentUpdaterManager.queueChange( + ctx.project_id, + ctx.doc_id, + ctx.change, + ctx.callback + ) + }) + + it('should remove the invalid keys from the change', function (ctx) { + ctx.rclient.rpush + .calledWith( + `PendingUpdates:${ctx.doc_id}`, + JSON.stringify({ op: ctx.change.op }) + ) + .should.equal(true) + }) + }) + }) + + describe('_getPendingUpdateListKey', function () { + beforeEach(function (ctx) { + const keys = _.times( + 10000, + ctx.DocumentUpdaterManager._getPendingUpdateListKey + ) + ctx.keys = _.uniq(keys) + }) + it('should return normal pending updates key', function (ctx) { + _.includes(ctx.keys, 'pending-updates-list').should.equal(true) + }) + + it('should return pending-updates-list-n keys', function (ctx) { + _.includes(ctx.keys, 'pending-updates-list-1').should.equal(true) + _.includes(ctx.keys, 'pending-updates-list-3').should.equal(true) + _.includes(ctx.keys, 'pending-updates-list-9').should.equal(true) + }) + + it('should not include pending-updates-list-0 key', function (ctx) { + _.includes(ctx.keys, 'pending-updates-list-0').should.equal(false) + }) + + it('should not include maximum as pendingUpdateListShardCount value', function (ctx) { + _.includes(ctx.keys, 'pending-updates-list-10').should.equal(false) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js deleted file mode 100644 index ecf45cd452..0000000000 --- a/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js +++ /dev/null @@ -1,423 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const sinon = require('sinon') -const SandboxedModule = require('sandboxed-module') -const path = require('node:path') -const modulePath = '../../../app/js/DocumentUpdaterManager' -const _ = require('lodash') - -describe('DocumentUpdaterManager', function () { - beforeEach(function () { - let Timer - this.project_id = 'project-id-923' - this.doc_id = 'doc-id-394' - this.lines = ['one', 'two', 'three'] - this.version = 42 - this.settings = { - apis: { documentupdater: { url: 'http://doc-updater.example.com' } }, - redis: { - documentupdater: { - key_schema: { - pendingUpdates({ doc_id: docId }) { - return `PendingUpdates:${docId}` - }, - }, - }, - }, - maxUpdateSize: 7 * 1024 * 1024, - pendingUpdateListShardCount: 10, - } - this.rclient = { auth() {} } - - return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.settings, - request: (this.request = {}), - '@overleaf/redis-wrapper': { createClient: () => this.rclient }, - '@overleaf/metrics': (this.Metrics = { - summary: sinon.stub(), - Timer: (Timer = class Timer { - done() {} - }), - }), - }, - })) - }) // avoid modifying JSON object directly - - describe('getDocument', function () { - beforeEach(function () { - return (this.callback = sinon.stub()) - }) - - describe('successfully', function () { - beforeEach(function () { - this.body = JSON.stringify({ - lines: this.lines, - version: this.version, - ops: (this.ops = ['mock-op-1', 'mock-op-2']), - ranges: (this.ranges = { mock: 'ranges' }), - }) - this.fromVersion = 2 - this.request.get = sinon - .stub() - .callsArgWith(1, null, { statusCode: 200 }, this.body) - return this.DocumentUpdaterManager.getDocument( - this.project_id, - this.doc_id, - this.fromVersion, - this.callback - ) - }) - - it('should get the document from the document updater', function () { - const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}&historyOTSupport=true` - return this.request.get.calledWith(url).should.equal(true) - }) - - return it('should call the callback with the lines, version, ranges and ops', function () { - return this.callback - .calledWith(null, this.lines, this.version, this.ranges, this.ops) - .should.equal(true) - }) - }) - - describe('when the document updater API returns an error', function () { - beforeEach(function () { - this.request.get = sinon - .stub() - .callsArgWith( - 1, - (this.error = new Error('something went wrong')), - null, - null - ) - return this.DocumentUpdaterManager.getDocument( - this.project_id, - this.doc_id, - this.fromVersion, - this.callback - ) - }) - - return it('should return an error to the callback', function () { - return this.callback.calledWith(this.error).should.equal(true) - }) - }) - ;[404, 422].forEach(statusCode => - describe(`when the document updater returns a ${statusCode} status code`, function () { - beforeEach(function () { - this.request.get = sinon - .stub() - .callsArgWith(1, null, { statusCode }, '') - return this.DocumentUpdaterManager.getDocument( - this.project_id, - this.doc_id, - this.fromVersion, - this.callback - ) - }) - - return it('should return the callback with an error', function () { - this.callback.called.should.equal(true) - this.callback - .calledWith( - sinon.match({ - message: 'doc updater could not load requested ops', - info: { statusCode }, - }) - ) - .should.equal(true) - this.logger.error.called.should.equal(false) - this.logger.warn.called.should.equal(false) - }) - }) - ) - - return describe('when the document updater returns a failure error code', function () { - beforeEach(function () { - this.request.get = sinon - .stub() - .callsArgWith(1, null, { statusCode: 500 }, '') - return this.DocumentUpdaterManager.getDocument( - this.project_id, - this.doc_id, - this.fromVersion, - this.callback - ) - }) - - return it('should return the callback with an error', function () { - this.callback.called.should.equal(true) - this.callback - .calledWith( - sinon.match({ - message: 'doc updater returned a non-success status code', - info: { - action: 'getDocument', - statusCode: 500, - }, - }) - ) - .should.equal(true) - this.logger.error.called.should.equal(false) - }) - }) - }) - - describe('flushProjectToMongoAndDelete', function () { - beforeEach(function () { - return (this.callback = sinon.stub()) - }) - - describe('successfully', function () { - beforeEach(function () { - this.request.del = sinon - .stub() - .callsArgWith(1, null, { statusCode: 204 }, '') - return this.DocumentUpdaterManager.flushProjectToMongoAndDelete( - this.project_id, - this.callback - ) - }) - - it('should delete the project from the document updater', function () { - const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}?background=true` - return this.request.del.calledWith(url).should.equal(true) - }) - - return it('should call the callback with no error', function () { - return this.callback.calledWith(null).should.equal(true) - }) - }) - - describe('when the document updater API returns an error', function () { - beforeEach(function () { - this.request.del = sinon - .stub() - .callsArgWith( - 1, - (this.error = new Error('something went wrong')), - null, - null - ) - return this.DocumentUpdaterManager.flushProjectToMongoAndDelete( - this.project_id, - this.callback - ) - }) - - return it('should return an error to the callback', function () { - return this.callback.calledWith(this.error).should.equal(true) - }) - }) - - return describe('when the document updater returns a failure error code', function () { - beforeEach(function () { - this.request.del = sinon - .stub() - .callsArgWith(1, null, { statusCode: 500 }, '') - return this.DocumentUpdaterManager.flushProjectToMongoAndDelete( - this.project_id, - this.callback - ) - }) - - return it('should return the callback with an error', function () { - this.callback.called.should.equal(true) - this.callback - .calledWith( - sinon.match({ - message: 'doc updater returned a non-success status code', - info: { - action: 'flushProjectToMongoAndDelete', - statusCode: 500, - }, - }) - ) - .should.equal(true) - }) - }) - }) - - describe('queueChange', function () { - beforeEach(function () { - this.change = { - doc: '1234567890', - op: [{ d: 'test', p: 345 }], - v: 789, - } - this.rclient.rpush = sinon.stub().yields() - return (this.callback = sinon.stub()) - }) - - describe('successfully', function () { - beforeEach(function () { - this.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}` - - this.DocumentUpdaterManager._getPendingUpdateListKey = sinon - .stub() - .returns(this.pendingUpdateListKey) - this.DocumentUpdaterManager.queueChange( - this.project_id, - this.doc_id, - this.change, - this.callback - ) - }) - - it('should push the change', function () { - this.rclient.rpush - .calledWith( - `PendingUpdates:${this.doc_id}`, - JSON.stringify(this.change) - ) - .should.equal(true) - }) - - it('should notify the doc updater of the change via the pending-updates-list queue', function () { - this.rclient.rpush - .calledWith( - this.pendingUpdateListKey, - `${this.project_id}:${this.doc_id}` - ) - .should.equal(true) - }) - }) - - describe('with error talking to redis during rpush', function () { - beforeEach(function () { - this.rclient.rpush = sinon - .stub() - .yields(new Error('something went wrong')) - return this.DocumentUpdaterManager.queueChange( - this.project_id, - this.doc_id, - this.change, - this.callback - ) - }) - - return it('should return an error', function () { - return this.callback - .calledWithExactly(sinon.match(Error)) - .should.equal(true) - }) - }) - - describe('with null byte corruption', function () { - beforeEach(function () { - this.stringifyStub = sinon - .stub(JSON, 'stringify') - .callsFake(() => '["bad bytes! \u0000 <- here"]') - return this.DocumentUpdaterManager.queueChange( - this.project_id, - this.doc_id, - this.change, - this.callback - ) - }) - - afterEach(function () { - this.stringifyStub.restore() - }) - - it('should return an error', function () { - return this.callback - .calledWithExactly(sinon.match(Error)) - .should.equal(true) - }) - - return it('should not push the change onto the pending-updates-list queue', function () { - return this.rclient.rpush.called.should.equal(false) - }) - }) - - describe('when the update is too large', function () { - beforeEach(function () { - this.change = { - op: { p: 12, t: 'update is too large'.repeat(1024 * 400) }, - } - return this.DocumentUpdaterManager.queueChange( - this.project_id, - this.doc_id, - this.change, - this.callback - ) - }) - - it('should return an error', function () { - return this.callback - .calledWithExactly(sinon.match(Error)) - .should.equal(true) - }) - - it('should add the size to the error', function () { - return this.callback.args[0][0].info.updateSize.should.equal(7782422) - }) - - return it('should not push the change onto the pending-updates-list queue', function () { - return this.rclient.rpush.called.should.equal(false) - }) - }) - - describe('with invalid keys', function () { - beforeEach(function () { - this.change = { - op: [{ d: 'test', p: 345 }], - version: 789, // not a valid key - } - return this.DocumentUpdaterManager.queueChange( - this.project_id, - this.doc_id, - this.change, - this.callback - ) - }) - - it('should remove the invalid keys from the change', function () { - return this.rclient.rpush - .calledWith( - `PendingUpdates:${this.doc_id}`, - JSON.stringify({ op: this.change.op }) - ) - .should.equal(true) - }) - }) - }) - - describe('_getPendingUpdateListKey', function () { - beforeEach(function () { - const keys = _.times( - 10000, - this.DocumentUpdaterManager._getPendingUpdateListKey - ) - this.keys = _.uniq(keys) - }) - it('should return normal pending updates key', function () { - _.includes(this.keys, 'pending-updates-list').should.equal(true) - }) - - it('should return pending-updates-list-n keys', function () { - _.includes(this.keys, 'pending-updates-list-1').should.equal(true) - _.includes(this.keys, 'pending-updates-list-3').should.equal(true) - _.includes(this.keys, 'pending-updates-list-9').should.equal(true) - }) - - it('should not include pending-updates-list-0 key', function () { - _.includes(this.keys, 'pending-updates-list-0').should.equal(false) - }) - - it('should not include maximum as pendingUpdateListShardCount value', function () { - _.includes(this.keys, 'pending-updates-list-10').should.equal(false) - }) - }) -}) diff --git a/services/real-time/test/unit/js/DrainManager.test.js b/services/real-time/test/unit/js/DrainManager.test.js new file mode 100644 index 0000000000..c9177168f9 --- /dev/null +++ b/services/real-time/test/unit/js/DrainManager.test.js @@ -0,0 +1,126 @@ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' +import { describe, beforeEach, it } from 'vitest' +import path from 'node:path' +const modulePath = path.join( + import.meta.dirname, + '../../../app/js/DrainManager' +) + +describe('DrainManager', function () { + beforeEach(async function (ctx) { + ctx.DrainManager = (await import(modulePath)).default + ctx.io = { + sockets: { + clients: sinon.stub(), + }, + } + }) + + describe('startDrainTimeWindow', function () { + beforeEach(function (ctx) { + ctx.clients = [] + for (let i = 0; i <= 5399; i++) { + ctx.clients[i] = { + id: i, + emit: sinon.stub(), + } + } + ctx.io.sockets.clients.returns(ctx.clients) + ctx.DrainManager.startDrain = sinon.stub() + }) + + it('should set a drain rate fast enough', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.DrainManager.startDrainTimeWindow(ctx.io, 9) + ctx.DrainManager.startDrain.calledWith(ctx.io, 10).should.equal(true) + resolve() + }) + }) + }) + + describe('reconnectNClients', function () { + beforeEach(function (ctx) { + ctx.clients = [] + for (let i = 0; i <= 9; i++) { + ctx.clients[i] = { + id: i, + emit: sinon.stub(), + } + } + ctx.io.sockets.clients.returns(ctx.clients) + }) + + describe('after first pass', function () { + beforeEach(function (ctx) { + ctx.DrainManager.reconnectNClients(ctx.io, 3) + }) + + it('should reconnect the first 3 clients', function (ctx) { + ;[0, 1, 2].map(i => + ctx.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(true) + ) + }) + + it('should not reconnect any more clients', function (ctx) { + ;[3, 4, 5, 6, 7, 8, 9].map(i => + ctx.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(false) + ) + }) + + describe('after second pass', function () { + beforeEach(function (ctx) { + ctx.DrainManager.reconnectNClients(ctx.io, 3) + }) + + it('should reconnect the next 3 clients', function (ctx) { + ;[3, 4, 5].map(i => + ctx.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(true) + ) + }) + + it('should not reconnect any more clients', function (ctx) { + ;[6, 7, 8, 9].map(i => + ctx.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(false) + ) + }) + + it('should not reconnect the first 3 clients again', function (ctx) { + ;[0, 1, 2].map(i => ctx.clients[i].emit.calledOnce.should.equal(true)) + }) + + describe('after final pass', function () { + beforeEach(function (ctx) { + ctx.DrainManager.reconnectNClients(ctx.io, 100) + }) + + it('should not reconnect the first 6 clients again', function (ctx) { + ;[0, 1, 2, 3, 4, 5].map(i => + ctx.clients[i].emit.calledOnce.should.equal(true) + ) + }) + + it('should log out that it reached the end', function (ctx) { + ctx.logger.info + .calledWith('All clients have been told to reconnectGracefully') + .should.equal(true) + }) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DrainManagerTests.js b/services/real-time/test/unit/js/DrainManagerTests.js deleted file mode 100644 index facdc5670d..0000000000 --- a/services/real-time/test/unit/js/DrainManagerTests.js +++ /dev/null @@ -1,127 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const sinon = require('sinon') -const SandboxedModule = require('sandboxed-module') -const path = require('node:path') -const modulePath = path.join(__dirname, '../../../app/js/DrainManager') - -describe('DrainManager', function () { - beforeEach(function () { - this.DrainManager = SandboxedModule.require(modulePath, {}) - return (this.io = { - sockets: { - clients: sinon.stub(), - }, - }) - }) - - describe('startDrainTimeWindow', function () { - beforeEach(function () { - this.clients = [] - for (let i = 0; i <= 5399; i++) { - this.clients[i] = { - id: i, - emit: sinon.stub(), - } - } - this.io.sockets.clients.returns(this.clients) - return (this.DrainManager.startDrain = sinon.stub()) - }) - - return it('should set a drain rate fast enough', function (done) { - this.DrainManager.startDrainTimeWindow(this.io, 9) - this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true) - return done() - }) - }) - - return describe('reconnectNClients', function () { - beforeEach(function () { - this.clients = [] - for (let i = 0; i <= 9; i++) { - this.clients[i] = { - id: i, - emit: sinon.stub(), - } - } - return this.io.sockets.clients.returns(this.clients) - }) - - return describe('after first pass', function () { - beforeEach(function () { - return this.DrainManager.reconnectNClients(this.io, 3) - }) - - it('should reconnect the first 3 clients', function () { - return [0, 1, 2].map(i => - this.clients[i].emit - .calledWith('reconnectGracefully') - .should.equal(true) - ) - }) - - it('should not reconnect any more clients', function () { - return [3, 4, 5, 6, 7, 8, 9].map(i => - this.clients[i].emit - .calledWith('reconnectGracefully') - .should.equal(false) - ) - }) - - return describe('after second pass', function () { - beforeEach(function () { - return this.DrainManager.reconnectNClients(this.io, 3) - }) - - it('should reconnect the next 3 clients', function () { - return [3, 4, 5].map(i => - this.clients[i].emit - .calledWith('reconnectGracefully') - .should.equal(true) - ) - }) - - it('should not reconnect any more clients', function () { - return [6, 7, 8, 9].map(i => - this.clients[i].emit - .calledWith('reconnectGracefully') - .should.equal(false) - ) - }) - - it('should not reconnect the first 3 clients again', function () { - return [0, 1, 2].map(i => - this.clients[i].emit.calledOnce.should.equal(true) - ) - }) - - return describe('after final pass', function () { - beforeEach(function () { - return this.DrainManager.reconnectNClients(this.io, 100) - }) - - it('should not reconnect the first 6 clients again', function () { - return [0, 1, 2, 3, 4, 5].map(i => - this.clients[i].emit.calledOnce.should.equal(true) - ) - }) - - return it('should log out that it reached the end', function () { - return this.logger.info - .calledWith('All clients have been told to reconnectGracefully') - .should.equal(true) - }) - }) - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/EventLogger.test.js b/services/real-time/test/unit/js/EventLogger.test.js new file mode 100644 index 0000000000..18e7cd6fbd --- /dev/null +++ b/services/real-time/test/unit/js/EventLogger.test.js @@ -0,0 +1,120 @@ +import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest' + +import sinon from 'sinon' +import tk from 'timekeeper' +const modulePath = '../../../app/js/EventLogger' + +describe('EventLogger', function () { + beforeEach(async function (ctx) { + ctx.start = Date.now() + tk.freeze(new Date(ctx.start)) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.metrics = { inc: sinon.stub() }), + })) + + ctx.EventLogger = (await import(modulePath)).default + ctx.channel = 'applied-ops' + ctx.id_1 = 'random-hostname:abc-1' + ctx.message_1 = 'message-1' + ctx.id_2 = 'random-hostname:abc-2' + ctx.message_2 = 'message-2' + }) + + afterEach(function () { + tk.reset() + }) + + describe('checkEventOrder', function () { + describe('when the events are in order', function () { + beforeEach(function (ctx) { + ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1) + ctx.status = ctx.EventLogger.checkEventOrder( + ctx.channel, + ctx.id_2, + ctx.message_2 + ) + }) + + it('should accept events in order', function (ctx) { + expect(ctx.status).to.be.undefined + }) + + it('should increment the valid event metric', function (ctx) { + ctx.metrics.inc + .calledWith(`event.${ctx.channel}.valid`) + .should.equals(true) + }) + }) + + describe('when there is a duplicate events', function () { + beforeEach(function (ctx) { + ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1) + ctx.status = ctx.EventLogger.checkEventOrder( + ctx.channel, + ctx.id_1, + ctx.message_1 + ) + }) + + it('should return "duplicate" for the same event', function (ctx) { + expect(ctx.status).to.equal('duplicate') + }) + + it('should increment the duplicate event metric', function (ctx) { + ctx.metrics.inc + .calledWith(`event.${ctx.channel}.duplicate`) + .should.equals(true) + }) + }) + + describe('when there are out of order events', function () { + beforeEach(function (ctx) { + ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1) + ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_2, ctx.message_2) + ctx.status = ctx.EventLogger.checkEventOrder( + ctx.channel, + ctx.id_1, + ctx.message_1 + ) + }) + + it('should return "out-of-order" for the event', function (ctx) { + expect(ctx.status).to.equal('out-of-order') + }) + + it('should increment the out-of-order event metric', function (ctx) { + ctx.metrics.inc + .calledWith(`event.${ctx.channel}.out-of-order`) + .should.equals(true) + }) + }) + + describe('after MAX_STALE_TIME_IN_MS', function () { + it('should flush old entries', function (ctx) { + let status + ctx.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10 + ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1) + for (let i = 1; i <= 8; i++) { + status = ctx.EventLogger.checkEventOrder( + ctx.channel, + ctx.id_1, + ctx.message_1 + ) + expect(status).to.equal('duplicate') + } + // the next event should flush the old entries aboce + ctx.EventLogger.MAX_STALE_TIME_IN_MS = 1000 + tk.freeze(new Date(ctx.start + 5 * 1000)) + // because we flushed the entries this should not be a duplicate + ctx.EventLogger.checkEventOrder(ctx.channel, 'other-1', ctx.message_2) + status = ctx.EventLogger.checkEventOrder( + ctx.channel, + ctx.id_1, + ctx.message_1 + ) + expect(status).to.be.undefined + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/EventLoggerTests.js b/services/real-time/test/unit/js/EventLoggerTests.js deleted file mode 100644 index 037f2e214a..0000000000 --- a/services/real-time/test/unit/js/EventLoggerTests.js +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint-disable - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const modulePath = '../../../app/js/EventLogger' -const sinon = require('sinon') -const tk = require('timekeeper') - -describe('EventLogger', function () { - beforeEach(function () { - this.start = Date.now() - tk.freeze(new Date(this.start)) - this.EventLogger = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/metrics': (this.metrics = { inc: sinon.stub() }), - }, - }) - this.channel = 'applied-ops' - this.id_1 = 'random-hostname:abc-1' - this.message_1 = 'message-1' - this.id_2 = 'random-hostname:abc-2' - return (this.message_2 = 'message-2') - }) - - afterEach(function () { - return tk.reset() - }) - - return describe('checkEventOrder', function () { - describe('when the events are in order', function () { - beforeEach(function () { - this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - ) - return (this.status = this.EventLogger.checkEventOrder( - this.channel, - this.id_2, - this.message_2 - )) - }) - - it('should accept events in order', function () { - return expect(this.status).to.be.undefined - }) - - return it('should increment the valid event metric', function () { - return this.metrics.inc - .calledWith(`event.${this.channel}.valid`) - .should.equals(true) - }) - }) - - describe('when there is a duplicate events', function () { - beforeEach(function () { - this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - ) - return (this.status = this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - )) - }) - - it('should return "duplicate" for the same event', function () { - return expect(this.status).to.equal('duplicate') - }) - - return it('should increment the duplicate event metric', function () { - return this.metrics.inc - .calledWith(`event.${this.channel}.duplicate`) - .should.equals(true) - }) - }) - - describe('when there are out of order events', function () { - beforeEach(function () { - this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - ) - this.EventLogger.checkEventOrder( - this.channel, - this.id_2, - this.message_2 - ) - return (this.status = this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - )) - }) - - it('should return "out-of-order" for the event', function () { - return expect(this.status).to.equal('out-of-order') - }) - - return it('should increment the out-of-order event metric', function () { - return this.metrics.inc - .calledWith(`event.${this.channel}.out-of-order`) - .should.equals(true) - }) - }) - - return describe('after MAX_STALE_TIME_IN_MS', function () { - return it('should flush old entries', function () { - let status - this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10 - this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - ) - for (let i = 1; i <= 8; i++) { - status = this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - ) - expect(status).to.equal('duplicate') - } - // the next event should flush the old entries aboce - this.EventLogger.MAX_STALE_TIME_IN_MS = 1000 - tk.freeze(new Date(this.start + 5 * 1000)) - // because we flushed the entries this should not be a duplicate - this.EventLogger.checkEventOrder( - this.channel, - 'other-1', - this.message_2 - ) - status = this.EventLogger.checkEventOrder( - this.channel, - this.id_1, - this.message_1 - ) - return expect(status).to.be.undefined - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/RoomManager.test.js b/services/real-time/test/unit/js/RoomManager.test.js new file mode 100644 index 0000000000..a92e6970df --- /dev/null +++ b/services/real-time/test/unit/js/RoomManager.test.js @@ -0,0 +1,403 @@ +import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest' + +import sinon from 'sinon' +const modulePath = '../../../app/js/RoomManager.js' + +describe('RoomManager', function () { + beforeEach(async function (ctx) { + ctx.project_id = 'project-id-123' + ctx.doc_id = 'doc-id-456' + ctx.other_doc_id = 'doc-id-789' + ctx.client = { namespace: { name: '' }, id: 'first-client' } + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = {}), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.metrics = { gauge: sinon.stub() }), + })) + + ctx.RoomManager = (await import(modulePath)).default + ctx.RoomManager._clientsInRoom = sinon.stub() + ctx.RoomManager._clientAlreadyInRoom = sinon.stub() + ctx.RoomEvents = ctx.RoomManager.eventSource() + sinon.spy(ctx.RoomEvents, 'emit') + sinon.spy(ctx.RoomEvents, 'once') + }) + + describe('emitOnCompletion', function () { + describe('when a subscribe errors', function () { + afterEach(function (ctx) { + process.removeListener('unhandledRejection', ctx.onUnhandled) + }) + + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.onUnhandled = error => { + ctx.unhandledError = error + reject(new Error(`unhandledRejection: ${error.message}`)) + } + process.on('unhandledRejection', ctx.onUnhandled) + + let rejectSubscribePromise + const subscribePromise = new Promise( + // eslint-disable-next-line promise/param-names + (_, r) => (rejectSubscribePromise = r) + ) + const promises = [subscribePromise] + const eventName = 'project-subscribed-123' + ctx.RoomEvents.once(eventName, () => setTimeout(resolve, 100)) + ctx.RoomManager.emitOnCompletion(promises, eventName) + setTimeout(() => + rejectSubscribePromise(new Error('subscribe failed')) + ) + }) + }) + + it('should keep going', function (ctx) { + expect(ctx.unhandledError).to.not.exist + }) + }) + }) + + describe('joinProject', function () { + describe('when the project room is empty', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.project_id) + .onFirstCall() + .returns(0) + ctx.client.join = sinon.stub() + ctx.callback = sinon.stub() + ctx.RoomEvents.on('project-active', id => { + setTimeout(() => { + ctx.RoomEvents.emit(`project-subscribed-${id}`) + }, 100) + }) + ctx.RoomManager.joinProject(ctx.client, ctx.project_id, err => { + ctx.callback(err) + resolve() + }) + }) + }) + + it("should emit a 'project-active' event with the id", function (ctx) { + ctx.RoomEvents.emit + .calledWithExactly('project-active', ctx.project_id) + .should.equal(true) + }) + + it("should listen for the 'project-subscribed-id' event", function (ctx) { + ctx.RoomEvents.once + .calledWith(`project-subscribed-${ctx.project_id}`) + .should.equal(true) + }) + + it('should join the room using the id', function (ctx) { + ctx.client.join.calledWithExactly(ctx.project_id).should.equal(true) + }) + }) + + describe('when there are other clients in the project room', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.project_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(124) + ctx.client.join = sinon.stub() + ctx.RoomManager.joinProject(ctx.client, ctx.project_id, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should join the room using the id', function (ctx) { + ctx.client.join.called.should.equal(true) + }) + + it('should not emit any events', function (ctx) { + ctx.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + + describe('joinDoc', function () { + describe('when the doc room is empty', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onFirstCall() + .returns(0) + ctx.client.join = sinon.stub() + ctx.callback = sinon.stub() + ctx.RoomEvents.on('doc-active', id => { + setTimeout(() => { + ctx.RoomEvents.emit(`doc-subscribed-${id}`) + }, 100) + }) + ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, err => { + ctx.callback(err) + resolve() + }) + }) + }) + + it("should emit a 'doc-active' event with the id", function (ctx) { + ctx.RoomEvents.emit + .calledWithExactly('doc-active', ctx.doc_id) + .should.equal(true) + }) + + it("should listen for the 'doc-subscribed-id' event", function (ctx) { + ctx.RoomEvents.once + .calledWith(`doc-subscribed-${ctx.doc_id}`) + .should.equal(true) + }) + + it('should join the room using the id', function (ctx) { + ctx.client.join.calledWithExactly(ctx.doc_id).should.equal(true) + }) + }) + + describe('when there are other clients in the doc room', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(124) + ctx.client.join = sinon.stub() + ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should join the room using the id', function (ctx) { + ctx.client.join.called.should.equal(true) + }) + + it('should not emit any events', function (ctx) { + ctx.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + + describe('leaveDoc', function () { + describe('when doc room will be empty after this client has left', function () { + beforeEach(function (ctx) { + ctx.RoomManager._clientAlreadyInRoom + .withArgs(ctx.client, ctx.doc_id) + .returns(true) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onCall(0) + .returns(0) + ctx.client.leave = sinon.stub() + ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id) + }) + + it('should leave the room using the id', function (ctx) { + ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true) + }) + + it("should emit a 'doc-empty' event with the id", function (ctx) { + ctx.RoomEvents.emit + .calledWithExactly('doc-empty', ctx.doc_id) + .should.equal(true) + }) + }) + + describe('when there are other clients in the doc room', function () { + beforeEach(function (ctx) { + ctx.RoomManager._clientAlreadyInRoom + .withArgs(ctx.client, ctx.doc_id) + .returns(true) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onCall(0) + .returns(123) + ctx.client.leave = sinon.stub() + ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id) + }) + + it('should leave the room using the id', function (ctx) { + ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true) + }) + + it('should not emit any events', function (ctx) { + ctx.RoomEvents.emit.called.should.equal(false) + }) + }) + + describe('when the client is not in the doc room', function () { + beforeEach(function (ctx) { + ctx.RoomManager._clientAlreadyInRoom + .withArgs(ctx.client, ctx.doc_id) + .returns(false) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onCall(0) + .returns(0) + ctx.client.leave = sinon.stub() + ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id) + }) + + it('should not leave the room', function (ctx) { + ctx.client.leave.called.should.equal(false) + }) + + it('should not emit any events', function (ctx) { + ctx.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + + describe('leaveProjectAndDocs', function () { + describe('when the client is connected to the project and multiple docs', function () { + beforeEach(function (ctx) { + ctx.RoomManager._roomsClientIsIn = sinon + .stub() + .returns([ctx.project_id, ctx.doc_id, ctx.other_doc_id]) + ctx.client.join = sinon.stub() + ctx.client.leave = sinon.stub() + }) + + describe('when this is the only client connected', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + // first call is for the join, + // second for the leave + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onCall(0) + .returns(0) + .onCall(1) + .returns(0) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.other_doc_id) + .onCall(0) + .returns(0) + .onCall(1) + .returns(0) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.project_id) + .onCall(0) + .returns(0) + .onCall(1) + .returns(0) + ctx.RoomManager._clientAlreadyInRoom + .withArgs(ctx.client, ctx.doc_id) + .returns(true) + .withArgs(ctx.client, ctx.other_doc_id) + .returns(true) + .withArgs(ctx.client, ctx.project_id) + .returns(true) + ctx.RoomEvents.on('project-active', id => { + setTimeout(() => { + ctx.RoomEvents.emit(`project-subscribed-${id}`) + }, 100) + }) + ctx.RoomEvents.on('doc-active', id => { + setTimeout(() => { + ctx.RoomEvents.emit(`doc-subscribed-${id}`) + }, 100) + }) + // put the client in the rooms + ctx.RoomManager.joinProject(ctx.client, ctx.project_id, () => { + ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, () => { + ctx.RoomManager.joinDoc(ctx.client, ctx.other_doc_id, () => { + // now leave the project + ctx.RoomManager.leaveProjectAndDocs(ctx.client) + resolve() + }) + }) + }) + }) + }) + + it('should leave all the docs', function (ctx) { + ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true) + ctx.client.leave + .calledWithExactly(ctx.other_doc_id) + .should.equal(true) + }) + + it('should leave the project', function (ctx) { + ctx.client.leave.calledWithExactly(ctx.project_id).should.equal(true) + }) + + it("should emit a 'doc-empty' event with the id for each doc", function (ctx) { + ctx.RoomEvents.emit + .calledWithExactly('doc-empty', ctx.doc_id) + .should.equal(true) + ctx.RoomEvents.emit + .calledWithExactly('doc-empty', ctx.other_doc_id) + .should.equal(true) + }) + + it("should emit a 'project-empty' event with the id for the project", function (ctx) { + ctx.RoomEvents.emit + .calledWithExactly('project-empty', ctx.project_id) + .should.equal(true) + }) + }) + + describe('when other clients are still connected', function () { + beforeEach(function (ctx) { + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(122) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.other_doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(122) + ctx.RoomManager._clientsInRoom + .withArgs(ctx.client, ctx.project_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(122) + ctx.RoomManager._clientAlreadyInRoom + .withArgs(ctx.client, ctx.doc_id) + .returns(true) + .withArgs(ctx.client, ctx.other_doc_id) + .returns(true) + .withArgs(ctx.client, ctx.project_id) + .returns(true) + ctx.RoomManager.leaveProjectAndDocs(ctx.client) + }) + + it('should leave all the docs', function (ctx) { + ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true) + ctx.client.leave + .calledWithExactly(ctx.other_doc_id) + .should.equal(true) + }) + + it('should leave the project', function (ctx) { + ctx.client.leave.calledWithExactly(ctx.project_id).should.equal(true) + }) + + it('should not emit any events', function (ctx) { + ctx.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/RoomManagerTests.js b/services/real-time/test/unit/js/RoomManagerTests.js deleted file mode 100644 index f33d2ecce2..0000000000 --- a/services/real-time/test/unit/js/RoomManagerTests.js +++ /dev/null @@ -1,412 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, - promise/param-names, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const { expect } = require('chai') -const sinon = require('sinon') -const modulePath = '../../../app/js/RoomManager.js' -const SandboxedModule = require('sandboxed-module') - -describe('RoomManager', function () { - beforeEach(function () { - this.project_id = 'project-id-123' - this.doc_id = 'doc-id-456' - this.other_doc_id = 'doc-id-789' - this.client = { namespace: { name: '' }, id: 'first-client' } - this.RoomManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': (this.settings = {}), - '@overleaf/metrics': (this.metrics = { gauge: sinon.stub() }), - }, - }) - this.RoomManager._clientsInRoom = sinon.stub() - this.RoomManager._clientAlreadyInRoom = sinon.stub() - this.RoomEvents = this.RoomManager.eventSource() - sinon.spy(this.RoomEvents, 'emit') - return sinon.spy(this.RoomEvents, 'once') - }) - - describe('emitOnCompletion', function () { - return describe('when a subscribe errors', function () { - afterEach(function () { - return process.removeListener('unhandledRejection', this.onUnhandled) - }) - - beforeEach(function (done) { - this.onUnhandled = error => { - this.unhandledError = error - return done(new Error(`unhandledRejection: ${error.message}`)) - } - process.on('unhandledRejection', this.onUnhandled) - - let reject - const subscribePromise = new Promise((_, r) => (reject = r)) - const promises = [subscribePromise] - const eventName = 'project-subscribed-123' - this.RoomEvents.once(eventName, () => setTimeout(done, 100)) - this.RoomManager.emitOnCompletion(promises, eventName) - return setTimeout(() => reject(new Error('subscribe failed'))) - }) - - return it('should keep going', function () { - return expect(this.unhandledError).to.not.exist - }) - }) - }) - - describe('joinProject', function () { - describe('when the project room is empty', function () { - beforeEach(function (done) { - this.RoomManager._clientsInRoom - .withArgs(this.client, this.project_id) - .onFirstCall() - .returns(0) - this.client.join = sinon.stub() - this.callback = sinon.stub() - this.RoomEvents.on('project-active', id => { - return setTimeout(() => { - return this.RoomEvents.emit(`project-subscribed-${id}`) - }, 100) - }) - return this.RoomManager.joinProject( - this.client, - this.project_id, - err => { - this.callback(err) - return done() - } - ) - }) - - it("should emit a 'project-active' event with the id", function () { - return this.RoomEvents.emit - .calledWithExactly('project-active', this.project_id) - .should.equal(true) - }) - - it("should listen for the 'project-subscribed-id' event", function () { - return this.RoomEvents.once - .calledWith(`project-subscribed-${this.project_id}`) - .should.equal(true) - }) - - return it('should join the room using the id', function () { - return this.client.join - .calledWithExactly(this.project_id) - .should.equal(true) - }) - }) - - return describe('when there are other clients in the project room', function () { - beforeEach(function (done) { - this.RoomManager._clientsInRoom - .withArgs(this.client, this.project_id) - .onFirstCall() - .returns(123) - .onSecondCall() - .returns(124) - this.client.join = sinon.stub() - this.RoomManager.joinProject(this.client, this.project_id, done) - }) - - it('should join the room using the id', function () { - return this.client.join.called.should.equal(true) - }) - - return it('should not emit any events', function () { - return this.RoomEvents.emit.called.should.equal(false) - }) - }) - }) - - describe('joinDoc', function () { - describe('when the doc room is empty', function () { - beforeEach(function (done) { - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onFirstCall() - .returns(0) - this.client.join = sinon.stub() - this.callback = sinon.stub() - this.RoomEvents.on('doc-active', id => { - return setTimeout(() => { - return this.RoomEvents.emit(`doc-subscribed-${id}`) - }, 100) - }) - return this.RoomManager.joinDoc(this.client, this.doc_id, err => { - this.callback(err) - return done() - }) - }) - - it("should emit a 'doc-active' event with the id", function () { - return this.RoomEvents.emit - .calledWithExactly('doc-active', this.doc_id) - .should.equal(true) - }) - - it("should listen for the 'doc-subscribed-id' event", function () { - return this.RoomEvents.once - .calledWith(`doc-subscribed-${this.doc_id}`) - .should.equal(true) - }) - - return it('should join the room using the id', function () { - return this.client.join - .calledWithExactly(this.doc_id) - .should.equal(true) - }) - }) - - return describe('when there are other clients in the doc room', function () { - beforeEach(function (done) { - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onFirstCall() - .returns(123) - .onSecondCall() - .returns(124) - this.client.join = sinon.stub() - this.RoomManager.joinDoc(this.client, this.doc_id, done) - }) - - it('should join the room using the id', function () { - return this.client.join.called.should.equal(true) - }) - - return it('should not emit any events', function () { - return this.RoomEvents.emit.called.should.equal(false) - }) - }) - }) - - describe('leaveDoc', function () { - describe('when doc room will be empty after this client has left', function () { - beforeEach(function () { - this.RoomManager._clientAlreadyInRoom - .withArgs(this.client, this.doc_id) - .returns(true) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onCall(0) - .returns(0) - this.client.leave = sinon.stub() - return this.RoomManager.leaveDoc(this.client, this.doc_id) - }) - - it('should leave the room using the id', function () { - return this.client.leave - .calledWithExactly(this.doc_id) - .should.equal(true) - }) - - return it("should emit a 'doc-empty' event with the id", function () { - return this.RoomEvents.emit - .calledWithExactly('doc-empty', this.doc_id) - .should.equal(true) - }) - }) - - describe('when there are other clients in the doc room', function () { - beforeEach(function () { - this.RoomManager._clientAlreadyInRoom - .withArgs(this.client, this.doc_id) - .returns(true) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onCall(0) - .returns(123) - this.client.leave = sinon.stub() - return this.RoomManager.leaveDoc(this.client, this.doc_id) - }) - - it('should leave the room using the id', function () { - return this.client.leave - .calledWithExactly(this.doc_id) - .should.equal(true) - }) - - return it('should not emit any events', function () { - return this.RoomEvents.emit.called.should.equal(false) - }) - }) - - return describe('when the client is not in the doc room', function () { - beforeEach(function () { - this.RoomManager._clientAlreadyInRoom - .withArgs(this.client, this.doc_id) - .returns(false) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onCall(0) - .returns(0) - this.client.leave = sinon.stub() - return this.RoomManager.leaveDoc(this.client, this.doc_id) - }) - - it('should not leave the room', function () { - return this.client.leave.called.should.equal(false) - }) - - return it('should not emit any events', function () { - return this.RoomEvents.emit.called.should.equal(false) - }) - }) - }) - - return describe('leaveProjectAndDocs', function () { - return describe('when the client is connected to the project and multiple docs', function () { - beforeEach(function () { - this.RoomManager._roomsClientIsIn = sinon - .stub() - .returns([this.project_id, this.doc_id, this.other_doc_id]) - this.client.join = sinon.stub() - return (this.client.leave = sinon.stub()) - }) - - describe('when this is the only client connected', function () { - beforeEach(function (done) { - // first call is for the join, - // second for the leave - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onCall(0) - .returns(0) - .onCall(1) - .returns(0) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.other_doc_id) - .onCall(0) - .returns(0) - .onCall(1) - .returns(0) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.project_id) - .onCall(0) - .returns(0) - .onCall(1) - .returns(0) - this.RoomManager._clientAlreadyInRoom - .withArgs(this.client, this.doc_id) - .returns(true) - .withArgs(this.client, this.other_doc_id) - .returns(true) - .withArgs(this.client, this.project_id) - .returns(true) - this.RoomEvents.on('project-active', id => { - return setTimeout(() => { - return this.RoomEvents.emit(`project-subscribed-${id}`) - }, 100) - }) - this.RoomEvents.on('doc-active', id => { - return setTimeout(() => { - return this.RoomEvents.emit(`doc-subscribed-${id}`) - }, 100) - }) - // put the client in the rooms - return this.RoomManager.joinProject( - this.client, - this.project_id, - () => { - return this.RoomManager.joinDoc(this.client, this.doc_id, () => { - return this.RoomManager.joinDoc( - this.client, - this.other_doc_id, - () => { - // now leave the project - this.RoomManager.leaveProjectAndDocs(this.client) - return done() - } - ) - }) - } - ) - }) - - it('should leave all the docs', function () { - this.client.leave.calledWithExactly(this.doc_id).should.equal(true) - return this.client.leave - .calledWithExactly(this.other_doc_id) - .should.equal(true) - }) - - it('should leave the project', function () { - return this.client.leave - .calledWithExactly(this.project_id) - .should.equal(true) - }) - - it("should emit a 'doc-empty' event with the id for each doc", function () { - this.RoomEvents.emit - .calledWithExactly('doc-empty', this.doc_id) - .should.equal(true) - return this.RoomEvents.emit - .calledWithExactly('doc-empty', this.other_doc_id) - .should.equal(true) - }) - - return it("should emit a 'project-empty' event with the id for the project", function () { - return this.RoomEvents.emit - .calledWithExactly('project-empty', this.project_id) - .should.equal(true) - }) - }) - - return describe('when other clients are still connected', function () { - beforeEach(function () { - this.RoomManager._clientsInRoom - .withArgs(this.client, this.doc_id) - .onFirstCall() - .returns(123) - .onSecondCall() - .returns(122) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.other_doc_id) - .onFirstCall() - .returns(123) - .onSecondCall() - .returns(122) - this.RoomManager._clientsInRoom - .withArgs(this.client, this.project_id) - .onFirstCall() - .returns(123) - .onSecondCall() - .returns(122) - this.RoomManager._clientAlreadyInRoom - .withArgs(this.client, this.doc_id) - .returns(true) - .withArgs(this.client, this.other_doc_id) - .returns(true) - .withArgs(this.client, this.project_id) - .returns(true) - return this.RoomManager.leaveProjectAndDocs(this.client) - }) - - it('should leave all the docs', function () { - this.client.leave.calledWithExactly(this.doc_id).should.equal(true) - return this.client.leave - .calledWithExactly(this.other_doc_id) - .should.equal(true) - }) - - it('should leave the project', function () { - return this.client.leave - .calledWithExactly(this.project_id) - .should.equal(true) - }) - - return it('should not emit any events', function () { - return this.RoomEvents.emit.called.should.equal(false) - }) - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/SafeJsonParse.test.js b/services/real-time/test/unit/js/SafeJsonParse.test.js new file mode 100644 index 0000000000..42ba487635 --- /dev/null +++ b/services/real-time/test/unit/js/SafeJsonParse.test.js @@ -0,0 +1,50 @@ +import { vi, expect, describe, beforeEach, it } from 'vitest' + +const modulePath = '../../../app/js/SafeJsonParse' + +describe('SafeJsonParse', function () { + beforeEach(async function (ctx) { + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.Settings = { + maxUpdateSize: 16 * 1024, + }), + })) + + ctx.SafeJsonParse = (await import(modulePath)).default + }) + + describe('parse', function () { + it('should parse documents correctly', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => { + if (error) return reject(error) + expect(parsed).to.deep.equal({ foo: 'bar' }) + resolve() + }) + }) + }) + + it('should return an error on bad data', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.SafeJsonParse.parse('blah', (error, parsed) => { + expect(error).to.exist + resolve() + }) + }) + }) + + it('should return an error on oversized data', async function (ctx) { + await new Promise((resolve, reject) => { + // we have a 2k overhead on top of max size + const bigBlob = Array(16 * 1024).join('A') + const data = `{"foo": "${bigBlob}"}` + ctx.Settings.maxUpdateSize = 2 * 1024 + ctx.SafeJsonParse.parse(data, (error, parsed) => { + ctx.logger.error.called.should.equal(false) + expect(error).to.exist + resolve() + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/SafeJsonParseTest.js b/services/real-time/test/unit/js/SafeJsonParseTest.js deleted file mode 100644 index e5712fc370..0000000000 --- a/services/real-time/test/unit/js/SafeJsonParseTest.js +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable - no-return-assign, - no-useless-escape, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const modulePath = '../../../app/js/SafeJsonParse' - -describe('SafeJsonParse', function () { - beforeEach(function () { - return (this.SafeJsonParse = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': (this.Settings = { - maxUpdateSize: 16 * 1024, - }), - }, - })) - }) - - return describe('parse', function () { - it('should parse documents correctly', function (done) { - return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => { - if (error) return done(error) - expect(parsed).to.deep.equal({ foo: 'bar' }) - return done() - }) - }) - - it('should return an error on bad data', function (done) { - return this.SafeJsonParse.parse('blah', (error, parsed) => { - expect(error).to.exist - return done() - }) - }) - - return it('should return an error on oversized data', function (done) { - // we have a 2k overhead on top of max size - const bigBlob = Array(16 * 1024).join('A') - const data = `{\"foo\": \"${bigBlob}\"}` - this.Settings.maxUpdateSize = 2 * 1024 - return this.SafeJsonParse.parse(data, (error, parsed) => { - this.logger.error.called.should.equal(false) - expect(error).to.exist - return done() - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/SessionSockets.test.js b/services/real-time/test/unit/js/SessionSockets.test.js new file mode 100644 index 0000000000..a34ad63e08 --- /dev/null +++ b/services/real-time/test/unit/js/SessionSockets.test.js @@ -0,0 +1,314 @@ +import { vi, expect, describe, beforeEach, it } from 'vitest' +import { EventEmitter } from 'node:events' +import sinon from 'sinon' +const modulePath = '../../../app/js/SessionSockets' + +describe('SessionSockets', function () { + beforeEach(async function (ctx) { + ctx.metrics = { inc: sinon.stub() } + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.metrics, + })) + + ctx.SessionSocketsModule = (await import(modulePath)).default + ctx.io = new EventEmitter() + ctx.id1 = Math.random().toString() + ctx.id2 = Math.random().toString() + const redisResponses = { + error: [new Error('Redis: something went wrong'), null], + unknownId: [null, null], + } + redisResponses[ctx.id1] = [null, { user: { _id: '123' } }] + redisResponses[ctx.id2] = [null, { user: { _id: 'abc' } }] + + ctx.sessionStore = { + get: sinon + .stub() + .callsFake((id, fn) => fn.apply(null, redisResponses[id])), + } + ctx.cookieParser = function (req, res, next) { + req.signedCookies = req._signedCookies + return next() + } + ctx.SessionSockets = ctx.SessionSocketsModule( + ctx.io, + ctx.sessionStore, + ctx.cookieParser, + 'ol.sid' + ) + ctx.checkSocket = (socket, fn) => { + ctx.SessionSockets.once('connection', fn) + return ctx.io.emit('connection', socket) + } + }) + + describe('without cookies', function () { + beforeEach(function (ctx) { + ctx.socket = { handshake: {} } + }) + + it('should return a lookup error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + resolve() + }) + }) + }) + + it('should not query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(false) + resolve() + }) + }) + }) + + it('should increment the session.cookie metric with status "none"', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, { + status: 'none', + }) + resolve() + }) + }) + }) + }) + + describe('with a different cookie', function () { + beforeEach(function (ctx) { + ctx.socket = { handshake: { _signedCookies: { other: 1 } } } + }) + + it('should return a lookup error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + resolve() + }) + }) + }) + + it('should not query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(false) + resolve() + }) + }) + }) + }) + + describe('with a cookie with an invalid signature', function () { + beforeEach(function (ctx) { + ctx.socket = { + handshake: { _signedCookies: { 'ol.sid': false } }, + } + }) + + it('should return a lookup error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + resolve() + }) + }) + }) + + it('should not query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(false) + resolve() + }) + }) + }) + + it('should increment the session.cookie metric with status=bad-signature', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, { + status: 'bad-signature', + }) + resolve() + }) + }) + }) + }) + + describe('with a valid cookie and a failing session lookup', function () { + beforeEach(function (ctx) { + ctx.socket = { + handshake: { _signedCookies: { 'ol.sid': 'error' } }, + } + }) + + it('should query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(true) + resolve() + }) + }) + }) + + it('should return a redis error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('Redis: something went wrong') + resolve() + }) + }) + }) + + it('should increment the session.cookie metric with status=error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, { + status: 'error', + }) + resolve() + }) + }) + }) + }) + + describe('with a valid cookie and no matching session', function () { + beforeEach(function (ctx) { + ctx.socket = { + handshake: { _signedCookies: { 'ol.sid': 'unknownId' } }, + } + }) + + it('should query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(true) + resolve() + }) + }) + }) + + it('should return a lookup error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + resolve() + }) + }) + }) + + it('should increment the session.cookie metric with status=missing', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, { + status: 'missing', + }) + resolve() + }) + }) + }) + }) + + describe('with a valid cookie and a matching session', function () { + beforeEach(function (ctx) { + ctx.socket = { + handshake: { _signedCookies: { 'ol.sid': ctx.id1 } }, + } + }) + + it('should query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(true) + resolve() + }) + }) + }) + + it('should not return an error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.not.exist + resolve() + }) + }) + }) + + it('should return the session', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, (error, s, session) => { + if (error) return reject(error) + expect(session).to.deep.equal({ user: { _id: '123' } }) + resolve() + }) + }) + }) + + it('should increment the session.cookie metric with status=signed', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, { + status: 'signed', + }) + resolve() + }) + }) + }) + }) + + describe('with a different valid cookie and matching session', function () { + beforeEach(function (ctx) { + ctx.socket = { + handshake: { _signedCookies: { 'ol.sid': ctx.id2 } }, + } + }) + + it('should query redis', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.sessionStore.get.called).to.equal(true) + resolve() + }) + }) + }) + + it('should not return an error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, error => { + expect(error).to.not.exist + resolve() + }) + }) + }) + + it('should return the other session', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, (error, s, session) => { + if (error) return reject(error) + expect(session).to.deep.equal({ user: { _id: 'abc' } }) + resolve() + }) + }) + }) + + it('should increment the session.cookie metric with status=error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.checkSocket(ctx.socket, () => { + expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, { + status: 'signed', + }) + resolve() + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/SessionSocketsTests.js b/services/real-time/test/unit/js/SessionSocketsTests.js deleted file mode 100644 index c2a9ad3b7a..0000000000 --- a/services/real-time/test/unit/js/SessionSocketsTests.js +++ /dev/null @@ -1,280 +0,0 @@ -/* eslint-disable - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const { EventEmitter } = require('node:events') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const modulePath = '../../../app/js/SessionSockets' -const sinon = require('sinon') - -describe('SessionSockets', function () { - beforeEach(function () { - this.metrics = { inc: sinon.stub() } - this.SessionSocketsModule = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/metrics': this.metrics, - }, - }) - this.io = new EventEmitter() - this.id1 = Math.random().toString() - this.id2 = Math.random().toString() - const redisResponses = { - error: [new Error('Redis: something went wrong'), null], - unknownId: [null, null], - } - redisResponses[this.id1] = [null, { user: { _id: '123' } }] - redisResponses[this.id2] = [null, { user: { _id: 'abc' } }] - - this.sessionStore = { - get: sinon - .stub() - .callsFake((id, fn) => fn.apply(null, redisResponses[id])), - } - this.cookieParser = function (req, res, next) { - req.signedCookies = req._signedCookies - return next() - } - this.SessionSockets = this.SessionSocketsModule( - this.io, - this.sessionStore, - this.cookieParser, - 'ol.sid' - ) - return (this.checkSocket = (socket, fn) => { - this.SessionSockets.once('connection', fn) - return this.io.emit('connection', socket) - }) - }) - - describe('without cookies', function () { - beforeEach(function () { - return (this.socket = { handshake: {} }) - }) - - it('should return a lookup error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - return done() - }) - }) - - it('should not query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(false) - return done() - }) - }) - - it('should increment the session.cookie metric with status "none"', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, { - status: 'none', - }) - return done() - }) - }) - }) - - describe('with a different cookie', function () { - beforeEach(function () { - return (this.socket = { handshake: { _signedCookies: { other: 1 } } }) - }) - - it('should return a lookup error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - return done() - }) - }) - - it('should not query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(false) - return done() - }) - }) - }) - - describe('with a cookie with an invalid signature', function () { - beforeEach(function () { - return (this.socket = { - handshake: { _signedCookies: { 'ol.sid': false } }, - }) - }) - - it('should return a lookup error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - return done() - }) - }) - - it('should not query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(false) - return done() - }) - }) - - it('should increment the session.cookie metric with status=bad-signature', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, { - status: 'bad-signature', - }) - return done() - }) - }) - }) - - describe('with a valid cookie and a failing session lookup', function () { - beforeEach(function () { - return (this.socket = { - handshake: { _signedCookies: { 'ol.sid': 'error' } }, - }) - }) - - it('should query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(true) - return done() - }) - }) - - it('should return a redis error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.exist - expect(error.message).to.equal('Redis: something went wrong') - return done() - }) - }) - - it('should increment the session.cookie metric with status=error', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, { - status: 'error', - }) - return done() - }) - }) - }) - - describe('with a valid cookie and no matching session', function () { - beforeEach(function () { - return (this.socket = { - handshake: { _signedCookies: { 'ol.sid': 'unknownId' } }, - }) - }) - - it('should query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(true) - return done() - }) - }) - - it('should return a lookup error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.exist - expect(error.message).to.equal('could not look up session by key') - return done() - }) - }) - - it('should increment the session.cookie metric with status=missing', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, { - status: 'missing', - }) - return done() - }) - }) - }) - - describe('with a valid cookie and a matching session', function () { - beforeEach(function () { - return (this.socket = { - handshake: { _signedCookies: { 'ol.sid': this.id1 } }, - }) - }) - - it('should query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(true) - return done() - }) - }) - - it('should not return an error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.not.exist - return done() - }) - }) - - it('should return the session', function (done) { - return this.checkSocket(this.socket, (error, s, session) => { - if (error) return done(error) - expect(session).to.deep.equal({ user: { _id: '123' } }) - return done() - }) - }) - - it('should increment the session.cookie metric with status=signed', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, { - status: 'signed', - }) - return done() - }) - }) - }) - - describe('with a different valid cookie and matching session', function () { - beforeEach(function () { - return (this.socket = { - handshake: { _signedCookies: { 'ol.sid': this.id2 } }, - }) - }) - - it('should query redis', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.sessionStore.get.called).to.equal(true) - return done() - }) - }) - - it('should not return an error', function (done) { - return this.checkSocket(this.socket, error => { - expect(error).to.not.exist - return done() - }) - }) - - it('should return the other session', function (done) { - return this.checkSocket(this.socket, (error, s, session) => { - if (error) return done(error) - expect(session).to.deep.equal({ user: { _id: 'abc' } }) - return done() - }) - }) - - it('should increment the session.cookie metric with status=error', function (done) { - return this.checkSocket(this.socket, () => { - expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, { - status: 'signed', - }) - return done() - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/WebApiManager.test.js b/services/real-time/test/unit/js/WebApiManager.test.js new file mode 100644 index 0000000000..27f502c235 --- /dev/null +++ b/services/real-time/test/unit/js/WebApiManager.test.js @@ -0,0 +1,255 @@ +import { vi, describe, beforeEach, it } from 'vitest' +/* eslint-disable + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +import sinon from 'sinon' + +const modulePath = '../../../app/js/WebApiManager.js' + +describe('WebApiManager', function () { + beforeEach(async function (ctx) { + ctx.project_id = 'project-id-123' + ctx.user_id = 'user-id-123' + ctx.user = { _id: ctx.user_id } + ctx.callback = sinon.stub() + + vi.doMock('request', () => ({ + default: (ctx.request = {}), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + apis: { + web: { + url: 'http://web.example.com', + user: 'username', + pass: 'password', + }, + }, + }), + })) + + return (ctx.WebApiManager = (await import(modulePath)).default) + }) + + return describe('joinProject', function () { + describe('successfully', function () { + beforeEach(function (ctx) { + ctx.response = { + project: { name: 'Test project' }, + privilegeLevel: 'owner', + isRestrictedUser: true, + isTokenMember: true, + isInvitedMember: true, + } + ctx.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 200 }, ctx.response) + return ctx.WebApiManager.joinProject( + ctx.project_id, + ctx.user, + ctx.callback + ) + }) + + it('should send a request to web to join the project', function (ctx) { + return ctx.request.post + .calledWith({ + url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`, + auth: { + user: ctx.settings.apis.web.user, + pass: ctx.settings.apis.web.pass, + sendImmediately: true, + }, + json: { + userId: ctx.user_id, + anonymousAccessToken: undefined, + }, + jar: false, + }) + .should.equal(true) + }) + + return it('should return the project, privilegeLevel, and restricted flag', function (ctx) { + return ctx.callback + .calledWith(null, ctx.response.project, ctx.response.privilegeLevel, { + isRestrictedUser: ctx.response.isRestrictedUser, + isTokenMember: ctx.response.isTokenMember, + isInvitedMember: ctx.response.isInvitedMember, + }) + .should.equal(true) + }) + }) + + describe('with anon user', function () { + beforeEach(function (ctx) { + ctx.user_id = 'anonymous-user' + ctx.token = 'a-ro-token' + ctx.user = { + _id: ctx.user_id, + anonymousAccessToken: ctx.token, + } + ctx.response = { + project: { name: 'Test project' }, + privilegeLevel: 'readOnly', + isRestrictedUser: true, + isTokenMember: false, + isInvitedMember: false, + } + ctx.request.post = sinon + .stub() + .yields(null, { statusCode: 200 }, ctx.response) + ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback) + }) + + it('should send a request to web to join the project', function (ctx) { + ctx.request.post.should.have.been.calledWith({ + url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`, + auth: { + user: ctx.settings.apis.web.user, + pass: ctx.settings.apis.web.pass, + sendImmediately: true, + }, + json: { + userId: ctx.user_id, + anonymousAccessToken: ctx.token, + }, + jar: false, + }) + }) + + it('should return the project, privilegeLevel, and restricted flag', function (ctx) { + ctx.callback.should.have.been.calledWith( + null, + ctx.response.project, + ctx.response.privilegeLevel, + { + isRestrictedUser: ctx.response.isRestrictedUser, + isTokenMember: ctx.response.isTokenMember, + isInvitedMember: ctx.response.isInvitedMember, + } + ) + }) + }) + + describe('when web replies with a 403', function () { + beforeEach(function (ctx) { + ctx.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 403 }, null) + ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback) + }) + + it('should call the callback with an error', function (ctx) { + ctx.callback + .calledWith( + sinon.match({ + message: 'not authorized', + }) + ) + .should.equal(true) + }) + }) + + describe('when web replies with a 404', function () { + beforeEach(function (ctx) { + ctx.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 404 }, null) + ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback) + }) + + it('should call the callback with an error', function (ctx) { + ctx.callback + .calledWith( + sinon.match({ + message: 'project not found', + info: { code: 'ProjectNotFound' }, + }) + ) + .should.equal(true) + }) + }) + + describe('with an error from web', function () { + beforeEach(function (ctx) { + ctx.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 500 }, null) + return ctx.WebApiManager.joinProject( + ctx.project_id, + ctx.user_id, + ctx.callback + ) + }) + + return it('should call the callback with an error', function (ctx) { + return ctx.callback + .calledWith( + sinon.match({ + message: 'non-success status code from web', + info: { statusCode: 500 }, + }) + ) + .should.equal(true) + }) + }) + + describe('with no data from web', function () { + beforeEach(function (ctx) { + ctx.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 200 }, null) + return ctx.WebApiManager.joinProject( + ctx.project_id, + ctx.user_id, + ctx.callback + ) + }) + + return it('should call the callback with an error', function (ctx) { + return ctx.callback + .calledWith( + sinon.match({ + message: 'no data returned from joinProject request', + }) + ) + .should.equal(true) + }) + }) + + return describe('when the project is over its rate limit', function () { + beforeEach(function (ctx) { + ctx.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 429 }, null) + return ctx.WebApiManager.joinProject( + ctx.project_id, + ctx.user_id, + ctx.callback + ) + }) + + return it('should call the callback with a TooManyRequests error code', function (ctx) { + return ctx.callback + .calledWith( + sinon.match({ + message: 'rate-limit hit when joining project', + info: { + code: 'TooManyRequests', + }, + }) + ) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebApiManagerTests.js b/services/real-time/test/unit/js/WebApiManagerTests.js deleted file mode 100644 index b68661c774..0000000000 --- a/services/real-time/test/unit/js/WebApiManagerTests.js +++ /dev/null @@ -1,268 +0,0 @@ -/* eslint-disable - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const sinon = require('sinon') -const modulePath = '../../../app/js/WebApiManager.js' -const SandboxedModule = require('sandboxed-module') -const { CodedError } = require('../../../app/js/Errors') - -describe('WebApiManager', function () { - beforeEach(function () { - this.project_id = 'project-id-123' - this.user_id = 'user-id-123' - this.user = { _id: this.user_id } - this.callback = sinon.stub() - return (this.WebApiManager = SandboxedModule.require(modulePath, { - requires: { - request: (this.request = {}), - '@overleaf/settings': (this.settings = { - apis: { - web: { - url: 'http://web.example.com', - user: 'username', - pass: 'password', - }, - }, - }), - }, - })) - }) - - return describe('joinProject', function () { - describe('successfully', function () { - beforeEach(function () { - this.response = { - project: { name: 'Test project' }, - privilegeLevel: 'owner', - isRestrictedUser: true, - isTokenMember: true, - isInvitedMember: true, - } - this.request.post = sinon - .stub() - .callsArgWith(1, null, { statusCode: 200 }, this.response) - return this.WebApiManager.joinProject( - this.project_id, - this.user, - this.callback - ) - }) - - it('should send a request to web to join the project', function () { - return this.request.post - .calledWith({ - url: `${this.settings.apis.web.url}/project/${this.project_id}/join`, - auth: { - user: this.settings.apis.web.user, - pass: this.settings.apis.web.pass, - sendImmediately: true, - }, - json: { - userId: this.user_id, - anonymousAccessToken: undefined, - }, - jar: false, - }) - .should.equal(true) - }) - - return it('should return the project, privilegeLevel, and restricted flag', function () { - return this.callback - .calledWith( - null, - this.response.project, - this.response.privilegeLevel, - { - isRestrictedUser: this.response.isRestrictedUser, - isTokenMember: this.response.isTokenMember, - isInvitedMember: this.response.isInvitedMember, - } - ) - .should.equal(true) - }) - }) - - describe('with anon user', function () { - beforeEach(function () { - this.user_id = 'anonymous-user' - this.token = 'a-ro-token' - this.user = { - _id: this.user_id, - anonymousAccessToken: this.token, - } - this.response = { - project: { name: 'Test project' }, - privilegeLevel: 'readOnly', - isRestrictedUser: true, - isTokenMember: false, - isInvitedMember: false, - } - this.request.post = sinon - .stub() - .yields(null, { statusCode: 200 }, this.response) - this.WebApiManager.joinProject( - this.project_id, - this.user, - this.callback - ) - }) - - it('should send a request to web to join the project', function () { - this.request.post.should.have.been.calledWith({ - url: `${this.settings.apis.web.url}/project/${this.project_id}/join`, - auth: { - user: this.settings.apis.web.user, - pass: this.settings.apis.web.pass, - sendImmediately: true, - }, - json: { - userId: this.user_id, - anonymousAccessToken: this.token, - }, - jar: false, - }) - }) - - it('should return the project, privilegeLevel, and restricted flag', function () { - this.callback.should.have.been.calledWith( - null, - this.response.project, - this.response.privilegeLevel, - { - isRestrictedUser: this.response.isRestrictedUser, - isTokenMember: this.response.isTokenMember, - isInvitedMember: this.response.isInvitedMember, - } - ) - }) - }) - - describe('when web replies with a 403', function () { - beforeEach(function () { - this.request.post = sinon - .stub() - .callsArgWith(1, null, { statusCode: 403 }, null) - this.WebApiManager.joinProject( - this.project_id, - this.user_id, - this.callback - ) - }) - - it('should call the callback with an error', function () { - this.callback - .calledWith( - sinon.match({ - message: 'not authorized', - }) - ) - .should.equal(true) - }) - }) - - describe('when web replies with a 404', function () { - beforeEach(function () { - this.request.post = sinon - .stub() - .callsArgWith(1, null, { statusCode: 404 }, null) - this.WebApiManager.joinProject( - this.project_id, - this.user_id, - this.callback - ) - }) - - it('should call the callback with an error', function () { - this.callback - .calledWith( - sinon.match({ - message: 'project not found', - info: { code: 'ProjectNotFound' }, - }) - ) - .should.equal(true) - }) - }) - - describe('with an error from web', function () { - beforeEach(function () { - this.request.post = sinon - .stub() - .callsArgWith(1, null, { statusCode: 500 }, null) - return this.WebApiManager.joinProject( - this.project_id, - this.user_id, - this.callback - ) - }) - - return it('should call the callback with an error', function () { - return this.callback - .calledWith( - sinon.match({ - message: 'non-success status code from web', - info: { statusCode: 500 }, - }) - ) - .should.equal(true) - }) - }) - - describe('with no data from web', function () { - beforeEach(function () { - this.request.post = sinon - .stub() - .callsArgWith(1, null, { statusCode: 200 }, null) - return this.WebApiManager.joinProject( - this.project_id, - this.user_id, - this.callback - ) - }) - - return it('should call the callback with an error', function () { - return this.callback - .calledWith( - sinon.match({ - message: 'no data returned from joinProject request', - }) - ) - .should.equal(true) - }) - }) - - return describe('when the project is over its rate limit', function () { - beforeEach(function () { - this.request.post = sinon - .stub() - .callsArgWith(1, null, { statusCode: 429 }, null) - return this.WebApiManager.joinProject( - this.project_id, - this.user_id, - this.callback - ) - }) - - return it('should call the callback with a TooManyRequests error code', function () { - return this.callback - .calledWith( - sinon.match({ - message: 'rate-limit hit when joining project', - info: { - code: 'TooManyRequests', - }, - }) - ) - .should.equal(true) - }) - }) - }) -}) diff --git a/services/real-time/test/unit/js/WebsocketAddressManagerTests.js b/services/real-time/test/unit/js/WebsocketAddressManager.test.js similarity index 62% rename from services/real-time/test/unit/js/WebsocketAddressManagerTests.js rename to services/real-time/test/unit/js/WebsocketAddressManager.test.js index 89d4598abf..15e49c5cd3 100644 --- a/services/real-time/test/unit/js/WebsocketAddressManagerTests.js +++ b/services/real-time/test/unit/js/WebsocketAddressManager.test.js @@ -1,28 +1,27 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const modulePath = require('node:path').join( - __dirname, +import { expect, describe, beforeEach, it } from 'vitest' +import path from 'node:path' + +const modulePath = path.join( + import.meta.dirname, '../../../app/js/WebsocketAddressManager' ) describe('WebsocketAddressManager', function () { - beforeEach(function () { - this.WebsocketAddressManager = SandboxedModule.require(modulePath, { - requires: {}, - }) + beforeEach(async function (ctx) { + ctx.WebsocketAddressManager = (await import(modulePath)).default }) describe('with a proxy configuration', function () { - beforeEach(function () { - this.websocketAddressManager = new this.WebsocketAddressManager( + beforeEach(function (ctx) { + ctx.websocketAddressManager = new ctx.WebsocketAddressManager( true, '127.0.0.1' ) }) - it('should return the client ip address when behind a proxy', function () { + it('should return the client ip address when behind a proxy', function (ctx) { expect( - this.websocketAddressManager.getRemoteIp({ + ctx.websocketAddressManager.getRemoteIp({ headers: { 'x-forwarded-proto': 'https', 'x-forwarded-for': '123.45.67.89', @@ -32,56 +31,56 @@ describe('WebsocketAddressManager', function () { ).to.equal('123.45.67.89') }) - it('should return the client ip address for a direct connection', function () { + it('should return the client ip address for a direct connection', function (ctx) { expect( - this.websocketAddressManager.getRemoteIp({ + ctx.websocketAddressManager.getRemoteIp({ headers: {}, address: { address: '123.45.67.89' }, }) ).to.equal('123.45.67.89') }) - it('should return the client ip address when there are no headers in the handshake', function () { + it('should return the client ip address when there are no headers in the handshake', function (ctx) { expect( - this.websocketAddressManager.getRemoteIp({ + ctx.websocketAddressManager.getRemoteIp({ address: { address: '123.45.67.89' }, }) ).to.equal('123.45.67.89') }) - it('should return a "client-handshake-missing" response when the handshake is missing', function () { - expect(this.websocketAddressManager.getRemoteIp()).to.equal( + it('should return a "client-handshake-missing" response when the handshake is missing', function (ctx) { + expect(ctx.websocketAddressManager.getRemoteIp()).to.equal( 'client-handshake-missing' ) }) }) describe('without a proxy configuration', function () { - beforeEach(function () { - this.websocketAddressManager = new this.WebsocketAddressManager(false) + beforeEach(function (ctx) { + ctx.websocketAddressManager = new ctx.WebsocketAddressManager(false) }) - it('should return the client ip address for a direct connection', function () { + it('should return the client ip address for a direct connection', function (ctx) { expect( - this.websocketAddressManager.getRemoteIp({ + ctx.websocketAddressManager.getRemoteIp({ headers: {}, address: { address: '123.45.67.89' }, }) ).to.equal('123.45.67.89') }) - it('should return undefined if the client ip address is not present', function () { + it('should return undefined if the client ip address is not present', function (ctx) { expect( - this.websocketAddressManager.getRemoteIp({ + ctx.websocketAddressManager.getRemoteIp({ headers: {}, address: { otherAddressProperty: '123.45.67.89' }, }) ).to.be.undefined }) - it('should return the proxy ip address if there is actually a proxy', function () { + it('should return the proxy ip address if there is actually a proxy', function (ctx) { expect( - this.websocketAddressManager.getRemoteIp({ + ctx.websocketAddressManager.getRemoteIp({ headers: { 'x-forwarded-proto': 'https', 'x-forwarded-for': '123.45.67.89', @@ -91,8 +90,8 @@ describe('WebsocketAddressManager', function () { ).to.equal('127.0.0.1') }) - it('should return a "client-handshake-missing" response when the handshake is missing', function () { - expect(this.websocketAddressManager.getRemoteIp()).to.equal( + it('should return a "client-handshake-missing" response when the handshake is missing', function (ctx) { + expect(ctx.websocketAddressManager.getRemoteIp()).to.equal( 'client-handshake-missing' ) }) diff --git a/services/real-time/test/unit/js/WebsocketController.test.js b/services/real-time/test/unit/js/WebsocketController.test.js new file mode 100644 index 0000000000..776b9e79ce --- /dev/null +++ b/services/real-time/test/unit/js/WebsocketController.test.js @@ -0,0 +1,1750 @@ +import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest' + +/* eslint-disable + 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 + */ +import sinon from 'sinon' +import tk from 'timekeeper' +import Errors from '../../../app/js/Errors.js' +const modulePath = '../../../app/js/WebsocketController.js' +const { UpdateTooLargeError } = Errors + +describe('WebsocketController', function () { + beforeEach(async function (ctx) { + tk.freeze(new Date()) + ctx.project_id = 'project-id-123' + ctx.user = { + _id: (ctx.user_id = 'user-id-123'), + first_name: 'James', + last_name: 'Allen', + email: 'james@example.com', + signUpDate: new Date('2014-01-01'), + loginCount: 42, + } + ctx.callback = sinon.stub() + ctx.client = { + disconnected: false, + id: (ctx.client_id = 'mock-client-id-123'), + publicId: `other-id-${Math.random()}`, + ol_context: {}, + joinLeaveEpoch: 0, + join: sinon.stub(), + leave: sinon.stub(), + } + + vi.doMock('../../../app/js/WebApiManager', () => ({ + default: (ctx.WebApiManager = {}), + })) + + vi.doMock('../../../app/js/AuthorizationManager', () => ({ + default: (ctx.AuthorizationManager = {}), + })) + + vi.doMock('../../../app/js/DocumentUpdaterManager', () => ({ + default: (ctx.DocumentUpdaterManager = {}), + })) + + vi.doMock('../../../app/js/ConnectedUsersManager', () => ({ + default: (ctx.ConnectedUsersManager = {}), + })) + + vi.doMock('../../../app/js/WebsocketLoadBalancer', () => ({ + default: (ctx.WebsocketLoadBalancer = {}), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.metrics = { + inc: sinon.stub(), + set: sinon.stub(), + }), + })) + + vi.doMock('../../../app/js/RoomManager', () => ({ + default: (ctx.RoomManager = {}), + })) + + ctx.WebsocketController = (await import(modulePath)).default + }) + + afterEach(function () { + return tk.reset() + }) + + describe('joinProject', function () { + describe('when authorised', function () { + beforeEach(function (ctx) { + ctx.client.id = 'mock-client-id' + ctx.project = { + name: 'Test Project', + owner: { + _id: (ctx.owner_id = 'mock-owner-id-123'), + }, + } + ctx.privilegeLevel = 'owner' + ctx.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgAsync(4) + ctx.isRestrictedUser = true + ctx.isTokenMember = true + ctx.isInvitedMember = true + ctx.WebApiManager.joinProject = sinon + .stub() + .callsArgWith(2, null, ctx.project, ctx.privilegeLevel, { + isRestrictedUser: ctx.isRestrictedUser, + isTokenMember: ctx.isTokenMember, + isInvitedMember: ctx.isInvitedMember, + }) + ctx.RoomManager.joinProject = sinon.stub().callsArg(2) + return ctx.WebsocketController.joinProject( + ctx.client, + ctx.user, + ctx.project_id, + ctx.callback + ) + }) + + it('should load the project from web', function (ctx) { + return ctx.WebApiManager.joinProject + .calledWith(ctx.project_id, ctx.user) + .should.equal(true) + }) + + it('should join the project room', function (ctx) { + return ctx.RoomManager.joinProject + .calledWith(ctx.client, ctx.project_id) + .should.equal(true) + }) + + it('should set the privilege level on the client', function (ctx) { + return ctx.client.ol_context.privilege_level.should.equal( + ctx.privilegeLevel + ) + }) + it("should set the user's id on the client", function (ctx) { + return ctx.client.ol_context.user_id.should.equal(ctx.user._id) + }) + it("should set the user's email on the client", function (ctx) { + return ctx.client.ol_context.email.should.equal(ctx.user.email) + }) + it("should set the user's first_name on the client", function (ctx) { + return ctx.client.ol_context.first_name.should.equal( + ctx.user.first_name + ) + }) + it("should set the user's last_name on the client", function (ctx) { + return ctx.client.ol_context.last_name.should.equal(ctx.user.last_name) + }) + it("should set the user's sign up date on the client", function (ctx) { + return ctx.client.ol_context.signup_date.should.equal( + ctx.user.signUpDate + ) + }) + it("should set the user's login_count on the client", function (ctx) { + return ctx.client.ol_context.login_count.should.equal( + ctx.user.loginCount + ) + }) + it('should set the connected time on the client', function (ctx) { + return ctx.client.ol_context.connected_time.should.equal(new Date()) + }) + it('should set the project_id on the client', function (ctx) { + return ctx.client.ol_context.project_id.should.equal(ctx.project_id) + }) + it('should set the project owner id on the client', function (ctx) { + return ctx.client.ol_context.owner_id.should.equal(ctx.owner_id) + }) + it('should set the is_restricted_user flag on the client', function (ctx) { + return ctx.client.ol_context.is_restricted_user.should.equal( + ctx.isRestrictedUser + ) + }) + it('should set the is_token_member flag on the client', function (ctx) { + ctx.client.ol_context.is_token_member.should.equal(ctx.isTokenMember) + }) + it('should set the is_invited_member flag on the client', function (ctx) { + ctx.client.ol_context.is_invited_member.should.equal( + ctx.isInvitedMember + ) + }) + it('should call the callback with the project, privilegeLevel and protocolVersion', function (ctx) { + return ctx.callback + .calledWith( + null, + ctx.project, + ctx.privilegeLevel, + ctx.WebsocketController.PROTOCOL_VERSION + ) + .should.equal(true) + }) + + it('should mark the user as connected in ConnectedUsersManager', function (ctx) { + return ctx.ConnectedUsersManager.updateUserPosition + .calledWith(ctx.project_id, ctx.client.publicId, ctx.user, null) + .should.equal(true) + }) + + return it('should increment the join-project metric', function (ctx) { + return ctx.metrics.inc + .calledWith('editor.join-project') + .should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function (ctx) { + ctx.WebApiManager.joinProject = sinon + .stub() + .callsArgWith(2, null, null, null) + return ctx.WebsocketController.joinProject( + ctx.client, + ctx.user, + ctx.project_id, + ctx.callback + ) + }) + + it('should return an error', function (ctx) { + return ctx.callback + .calledWith(sinon.match({ message: 'not authorized' })) + .should.equal(true) + }) + + return it('should not log an error', function (ctx) { + return ctx.logger.error.called.should.equal(false) + }) + }) + + describe('when the subscribe failed', function () { + beforeEach(function (ctx) { + ctx.client.id = 'mock-client-id' + ctx.project = { + name: 'Test Project', + owner: { + _id: (ctx.owner_id = 'mock-owner-id-123'), + }, + } + ctx.privilegeLevel = 'owner' + ctx.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgAsync(4) + ctx.isRestrictedUser = true + ctx.isTokenMember = true + ctx.isInvitedMember = true + ctx.WebApiManager.joinProject = sinon + .stub() + .callsArgWith(2, null, ctx.project, ctx.privilegeLevel, { + isRestrictedUser: ctx.isRestrictedUser, + isTokenMember: ctx.isTokenMember, + isInvitedMember: ctx.isInvitedMember, + }) + ctx.RoomManager.joinProject = sinon + .stub() + .callsArgWith(2, new Error('subscribe failed')) + return ctx.WebsocketController.joinProject( + ctx.client, + ctx.user, + ctx.project_id, + ctx.callback + ) + }) + + return it('should return an error', function (ctx) { + ctx.callback + .calledWith(sinon.match({ message: 'subscribe failed' })) + .should.equal(true) + return ctx.callback.args[0][0].message.should.equal('subscribe failed') + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function (ctx) { + ctx.client.disconnected = true + ctx.WebApiManager.joinProject = sinon.stub().callsArg(2) + return ctx.WebsocketController.joinProject( + ctx.client, + ctx.user, + ctx.project_id, + ctx.callback + ) + }) + + it('should not call WebApiManager.joinProject', function (ctx) { + return expect(ctx.WebApiManager.joinProject.called).to.equal(false) + }) + + it('should call the callback with no details', function (ctx) { + return expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-project.disconnected metric with a status', function (ctx) { + return expect( + ctx.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 (ctx) { + ctx.WebApiManager.joinProject = (project, user, cb) => { + ctx.client.disconnected = true + return cb(null, ctx.project, ctx.privilegeLevel, { + isRestrictedUser: ctx.isRestrictedUser, + isTokenMember: ctx.isTokenMember, + isInvitedMember: ctx.isInvitedMember, + }) + } + + return ctx.WebsocketController.joinProject( + ctx.client, + ctx.user, + ctx.project_id, + ctx.callback + ) + }) + + it('should call the callback with no details', function (ctx) { + return expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-project.disconnected metric with a status', function (ctx) { + return expect( + ctx.metrics.inc.calledWith('editor.join-project.disconnected', 1, { + status: 'after-web-api-call', + }) + ).to.equal(true) + }) + }) + }) + + describe('leaveProject', function () { + beforeEach(function (ctx) { + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon + .stub() + .callsArg(1) + ctx.ConnectedUsersManager.markUserAsDisconnected = sinon + .stub() + .callsArg(2) + ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub() + ctx.RoomManager.leaveProjectAndDocs = sinon.stub() + ctx.clientsInRoom = [] + ctx.io = { + sockets: { + clients: roomId => { + if (roomId !== ctx.project_id) { + throw 'expected room_id to be project_id' + } + return ctx.clientsInRoom + }, + }, + } + ctx.client.ol_context.project_id = ctx.project_id + ctx.client.ol_context.user_id = ctx.user_id + ctx.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0 + tk.reset() + }) // Allow setTimeout to work. + + describe('when the client did not joined a project yet', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context = {} + ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should bail out when calling leaveProject', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom.called.should.equal(false) + ctx.RoomManager.leaveProjectAndDocs.called.should.equal(false) + ctx.ConnectedUsersManager.markUserAsDisconnected.called.should.equal( + false + ) + }) + + it('should not inc any metric', function (ctx) { + ctx.metrics.inc.called.should.equal(false) + }) + }) + + describe('when the project is empty', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.clientsInRoom = [] + ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should end clientTracking.clientDisconnected to the project room', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith( + ctx.project_id, + 'clientTracking.clientDisconnected', + ctx.client.publicId + ) + .should.equal(true) + }) + + it('should mark the user as disconnected', function (ctx) { + ctx.ConnectedUsersManager.markUserAsDisconnected + .calledWith(ctx.project_id, ctx.client.publicId) + .should.equal(true) + }) + + it('should flush the project in the document updater', function (ctx) { + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(ctx.project_id) + .should.equal(true) + }) + + it('should increment the leave-project metric', function (ctx) { + ctx.metrics.inc.calledWith('editor.leave-project').should.equal(true) + }) + + it('should track the disconnection in RoomManager', function (ctx) { + ctx.RoomManager.leaveProjectAndDocs + .calledWith(ctx.client) + .should.equal(true) + }) + }) + + describe('when the project is not empty', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.clientsInRoom = ['mock-remaining-client'] + ctx.io = { + sockets: { + clients: roomId => { + if (roomId !== ctx.project_id) { + throw 'expected room_id to be project_id' + } + return ctx.clientsInRoom + }, + }, + } + ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should not flush the project in the document updater', function (ctx) { + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete.called.should.equal( + false + ) + }) + }) + + describe('when client has not authenticated', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context.user_id = null + ctx.client.ol_context.project_id = null + ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should not end clientTracking.clientDisconnected to the project room', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith( + ctx.project_id, + 'clientTracking.clientDisconnected', + ctx.client.publicId + ) + .should.equal(false) + }) + + it('should not mark the user as disconnected', function (ctx) { + ctx.ConnectedUsersManager.markUserAsDisconnected + .calledWith(ctx.project_id, ctx.client.publicId) + .should.equal(false) + }) + + it('should not flush the project in the document updater', function (ctx) { + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(ctx.project_id) + .should.equal(false) + }) + + it('should not increment the leave-project metric', function (ctx) { + ctx.metrics.inc.calledWith('editor.leave-project').should.equal(false) + }) + }) + + describe('when client has not joined a project', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context.user_id = ctx.user_id + ctx.client.ol_context.project_id = null + ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => { + if (err) return reject(err) + resolve() + }) + }) + }) + + it('should not end clientTracking.clientDisconnected to the project room', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith( + ctx.project_id, + 'clientTracking.clientDisconnected', + ctx.client.publicId + ) + .should.equal(false) + }) + + it('should not mark the user as disconnected', function (ctx) { + ctx.ConnectedUsersManager.markUserAsDisconnected + .calledWith(ctx.project_id, ctx.client.publicId) + .should.equal(false) + }) + + it('should not flush the project in the document updater', function (ctx) { + ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(ctx.project_id) + .should.equal(false) + }) + + it('should not increment the leave-project metric', function (ctx) { + ctx.metrics.inc.calledWith('editor.leave-project').should.equal(false) + }) + }) + }) + + describe('joinDoc', function () { + beforeEach(function (ctx) { + ctx.doc_id = 'doc-id-123' + ctx.doc_lines = ['doc', 'lines'] + ctx.version = 42 + ctx.ops = ['mock', 'ops'] + ctx.ranges = { mock: 'ranges' } + ctx.options = {} + + ctx.client.ol_context.project_id = ctx.project_id + ctx.client.ol_context.is_restricted_user = false + ctx.AuthorizationManager.addAccessToDoc = sinon.stub().yields() + ctx.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon + .stub() + .callsArgWith(2, null) + ctx.DocumentUpdaterManager.getDocument = sinon + .stub() + .callsArgWith(3, null, ctx.doc_lines, ctx.version, ctx.ranges, ctx.ops) + ctx.RoomManager.joinDoc = sinon.stub().callsArg(2) + }) + + describe('works', function () { + beforeEach(function (ctx) { + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should inc the joinLeaveEpoch', function (ctx) { + expect(ctx.client.joinLeaveEpoch).to.equal(1) + }) + + it('should check that the client is authorized to view the project', function (ctx) { + ctx.AuthorizationManager.assertClientCanViewProject + .calledWith(ctx.client) + .should.equal(true) + }) + + it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) { + ctx.DocumentUpdaterManager.getDocument + .calledWith(ctx.project_id, ctx.doc_id, -1) + .should.equal(true) + }) + + it('should add permissions for the client to access the doc', function (ctx) { + ctx.AuthorizationManager.addAccessToDoc + .calledWith(ctx.client, ctx.doc_id) + .should.equal(true) + }) + + it('should join the client to room for the doc_id', function (ctx) { + ctx.RoomManager.joinDoc + .calledWith(ctx.client, ctx.doc_id) + .should.equal(true) + }) + + it('should call the callback with the lines, version, ranges and ops', function (ctx) { + ctx.callback + .calledWith(null, ctx.doc_lines, ctx.version, ctx.ops, ctx.ranges) + .should.equal(true) + }) + + it('should increment the join-doc metric', function (ctx) { + ctx.metrics.inc.calledWith('editor.join-doc').should.equal(true) + }) + }) + + describe('with a fromVersion', function () { + beforeEach(function (ctx) { + ctx.fromVersion = 40 + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + ctx.fromVersion, + ctx.options, + ctx.callback + ) + }) + + it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) { + ctx.DocumentUpdaterManager.getDocument + .calledWith(ctx.project_id, ctx.doc_id, ctx.fromVersion) + .should.equal(true) + }) + }) + + describe('with doclines that need escaping', function () { + beforeEach(function (ctx) { + ctx.doc_lines.push(['räksmörgås']) + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should call the callback with the escaped lines', function (ctx) { + const escapedLines = ctx.callback.args[0][1] + const escapedWord = escapedLines.pop() + escapedWord.should.equal('räksmörgÃ¥s') + // Check that unescaping works + decodeURIComponent(escape(escapedWord)).should.equal('räksmörgås') + }) + }) + + describe('with comments that need encoding', function () { + beforeEach(function (ctx) { + ctx.ranges.comments = [{ op: { c: 'räksmörgås' } }] + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + { encodeRanges: true }, + ctx.callback + ) + }) + + it('should call the callback with the encoded comment', function (ctx) { + const encodedComments = ctx.callback.args[0][4] + const encodedComment = encodedComments.comments.pop() + const encodedCommentText = encodedComment.op.c + encodedCommentText.should.equal('räksmörgÃ¥s') + }) + }) + + describe('with changes that need encoding', function () { + it('should call the callback with the encoded insert change', function (ctx) { + ctx.ranges.changes = [{ op: { i: 'räksmörgås' } }] + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + { encodeRanges: true }, + ctx.callback + ) + + const encodedChanges = ctx.callback.args[0][4] + const encodedChange = encodedChanges.changes.pop() + const encodedChangeText = encodedChange.op.i + encodedChangeText.should.equal('räksmörgÃ¥s') + }) + + it('should call the callback with the encoded delete change', function (ctx) { + ctx.ranges.changes = [{ op: { d: 'räksmörgås' } }] + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + { encodeRanges: true }, + ctx.callback + ) + + const encodedChanges = ctx.callback.args[0][4] + const encodedChange = encodedChanges.changes.pop() + const encodedChangeText = encodedChange.op.d + encodedChangeText.should.equal('räksmörgÃ¥s') + }) + }) + + describe('when not authorized', function () { + beforeEach(function (ctx) { + ctx.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, (ctx.err = new Error('not authorized'))) + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should call the callback with an error', function (ctx) { + ctx.callback + .calledWith(sinon.match({ message: 'not authorized' })) + .should.equal(true) + }) + + it('should not call the DocumentUpdaterManager', function (ctx) { + ctx.DocumentUpdaterManager.getDocument.called.should.equal(false) + }) + }) + + describe('with a restricted client', function () { + beforeEach(function (ctx) { + ctx.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }] + ctx.client.ol_context.is_restricted_user = true + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should overwrite ranges.comments with an empty list', function (ctx) { + const ranges = ctx.callback.args[0][4] + expect(ranges.comments).to.deep.equal([]) + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function (ctx) { + ctx.client.disconnected = true + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should call the callback with no details', function (ctx) { + expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) { + expect( + ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'immediately', + }) + ).to.equal(true) + }) + + it('should not get the document', function (ctx) { + expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false) + }) + }) + + describe('when the client disconnects while auth checks are running', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error() + ) + ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => { + ctx.client.disconnected = true + cb() + } + + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + (...args) => { + ctx.callback(...args) + resolve() + } + ) + }) + }) + + it('should call the callback with no details', function (ctx) { + expect(ctx.callback.called).to.equal(true) + expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) { + expect( + ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-client-auth-check', + }) + ).to.equal(true) + }) + + it('should not get the document', function (ctx) { + expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false) + }) + }) + + describe('when the client starts a parallel joinDoc request', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error() + ) + ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => { + ctx.DocumentUpdaterManager.checkDocument = sinon.stub().yields() + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + {}, + () => {} + ) + cb() + } + + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + (...args) => { + ctx.callback(...args) + // make sure the other joinDoc request completed + setTimeout(resolve, 5) + } + ) + }) + }) + + it('should call the callback with an error', function (ctx) { + expect(ctx.callback.called).to.equal(true) + expect(ctx.callback.args[0][0].message).to.equal( + 'joinLeaveEpoch mismatch' + ) + }) + + it('should get the document once (the parallel request wins)', function (ctx) { + expect(ctx.DocumentUpdaterManager.getDocument.callCount).to.equal(1) + }) + }) + + describe('when the client starts a parallel leaveDoc request', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.RoomManager.leaveDoc = sinon.stub() + + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error() + ) + ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => { + ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, () => {}) + cb() + } + + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + (...args) => { + ctx.callback(...args) + resolve() + } + ) + }) + }) + + it('should call the callback with an error', function (ctx) { + expect(ctx.callback.called).to.equal(true) + expect(ctx.callback.args[0][0].message).to.equal( + 'joinLeaveEpoch mismatch' + ) + }) + + it('should not get the document', function (ctx) { + expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false) + }) + }) + + describe('when the client disconnects while RoomManager.joinDoc is running', function () { + beforeEach(function (ctx) { + ctx.RoomManager.joinDoc = (client, docId, cb) => { + ctx.client.disconnected = true + cb() + } + + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should call the callback with no details', function (ctx) { + expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) { + expect( + ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-joining-room', + }) + ).to.equal(true) + }) + + it('should not get the document', function (ctx) { + expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false) + }) + }) + + describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () { + beforeEach(function (ctx) { + ctx.DocumentUpdaterManager.getDocument = ( + projectId, + docId, + fromVersion, + callback + ) => { + ctx.client.disconnected = true + callback(null, ctx.doc_lines, ctx.version, ctx.ranges, ctx.ops) + } + + ctx.WebsocketController.joinDoc( + ctx.client, + ctx.doc_id, + -1, + ctx.options, + ctx.callback + ) + }) + + it('should call the callback with no details', function (ctx) { + expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) { + expect( + ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-doc-updater-call', + }) + ).to.equal(true) + }) + }) + }) + + describe('leaveDoc', function () { + beforeEach(function (ctx) { + ctx.doc_id = 'doc-id-123' + ctx.client.ol_context.project_id = ctx.project_id + ctx.RoomManager.leaveDoc = sinon.stub() + ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, ctx.callback) + }) + + it('should inc the joinLeaveEpoch', function (ctx) { + expect(ctx.client.joinLeaveEpoch).to.equal(1) + }) + + it('should remove the client from the doc_id room', function (ctx) { + ctx.RoomManager.leaveDoc + .calledWith(ctx.client, ctx.doc_id) + .should.equal(true) + }) + + it('should call the callback', function (ctx) { + ctx.callback.called.should.equal(true) + }) + + it('should increment the leave-doc metric', function (ctx) { + ctx.metrics.inc.calledWith('editor.leave-doc').should.equal(true) + }) + }) + + describe('getConnectedUsers', function () { + beforeEach(function (ctx) { + ctx.client.ol_context.project_id = ctx.project_id + ctx.users = ['mock', 'users'] + ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub() + ctx.ConnectedUsersManager.getConnectedUsers = sinon + .stub() + .callsArgWith(1, null, ctx.users) + }) + + describe('when authorized', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + ctx.WebsocketController.getConnectedUsers(ctx.client, (...args) => { + ctx.callback(...Array.from(args || [])) + resolve() + }) + }) + }) + + it('should check that the client is authorized to view the project', function (ctx) { + ctx.AuthorizationManager.assertClientCanViewProject + .calledWith(ctx.client) + .should.equal(true) + }) + + it('should broadcast a request to update the client list', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith(ctx.project_id, 'clientTracking.refresh') + .should.equal(true) + }) + + it('should get the connected users for the project', function (ctx) { + ctx.ConnectedUsersManager.getConnectedUsers + .calledWith(ctx.project_id) + .should.equal(true) + }) + + it('should the users', function (ctx) { + ctx.callback.calledWith(null, ctx.users).should.equal(true) + }) + + it('should increment the get-connected-users metric', function (ctx) { + ctx.metrics.inc + .calledWith('editor.get-connected-users') + .should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function (ctx) { + ctx.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, (ctx.err = new Error('not authorized'))) + ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback) + }) + + it('should not get the connected users for the project', function (ctx) { + ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false) + }) + + it('should return an error', function (ctx) { + ctx.callback.calledWith(ctx.err).should.equal(true) + }) + }) + + describe('when restricted user', function () { + beforeEach(function (ctx) { + ctx.client.ol_context.is_restricted_user = true + ctx.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback) + }) + + it('should return an empty array of users', function (ctx) { + ctx.callback.calledWith(null, []).should.equal(true) + }) + + it('should not get the connected users for the project', function (ctx) { + ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false) + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function (ctx) { + ctx.client.disconnected = true + ctx.AuthorizationManager.assertClientCanViewProject = sinon.stub() + ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback) + }) + + it('should call the callback with no details', function (ctx) { + expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + it('should not check permissions', function (ctx) { + expect( + ctx.AuthorizationManager.assertClientCanViewProject.called + ).to.equal(false) + }) + }) + }) + + describe('updateClientPosition', function () { + beforeEach(function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub() + ctx.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgAsync(4) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon + .stub() + .callsArgWith(2, null) + ctx.update = { + doc_id: (ctx.doc_id = 'doc-id-123'), + row: (ctx.row = 42), + column: (ctx.column = 37), + } + }) + + describe('with a logged in user', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context = { + project_id: ctx.project_id, + first_name: (ctx.first_name = 'Douglas'), + last_name: (ctx.last_name = 'Adams'), + email: (ctx.email = 'joe@example.com'), + user_id: (ctx.user_id = 'user-id-123'), + } + + ctx.populatedCursorData = { + doc_id: ctx.doc_id, + id: ctx.client.publicId, + name: `${ctx.first_name} ${ctx.last_name}`, + row: ctx.row, + column: ctx.column, + email: ctx.email, + user_id: ctx.user_id, + } + ctx.WebsocketController.updateClientPosition( + ctx.client, + ctx.update, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it("should send the update to the project room with the user's name", function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith( + ctx.project_id, + 'clientTracking.clientUpdated', + ctx.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition + .calledWith( + ctx.project_id, + ctx.client.publicId, + { + _id: ctx.user_id, + email: ctx.email, + first_name: ctx.first_name, + last_name: ctx.last_name, + }, + { + row: ctx.row, + column: ctx.column, + doc_id: ctx.doc_id, + } + ) + .should.equal(true) + resolve() + }) + }) + + it('should increment the update-client-position metric at 0.1 frequency', function (ctx) { + ctx.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(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context = { + project_id: ctx.project_id, + first_name: (ctx.first_name = 'Douglas'), + last_name: undefined, + email: (ctx.email = 'joe@example.com'), + user_id: (ctx.user_id = 'user-id-123'), + } + + ctx.populatedCursorData = { + doc_id: ctx.doc_id, + id: ctx.client.publicId, + name: `${ctx.first_name}`, + row: ctx.row, + column: ctx.column, + email: ctx.email, + user_id: ctx.user_id, + } + ctx.WebsocketController.updateClientPosition( + ctx.client, + ctx.update, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it("should send the update to the project room with the user's name", function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith( + ctx.project_id, + 'clientTracking.clientUpdated', + ctx.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition + .calledWith( + ctx.project_id, + ctx.client.publicId, + { + _id: ctx.user_id, + email: ctx.email, + first_name: ctx.first_name, + last_name: undefined, + }, + { + row: ctx.row, + column: ctx.column, + doc_id: ctx.doc_id, + } + ) + .should.equal(true) + resolve() + }) + }) + + it('should increment the update-client-position metric at 0.1 frequency', function (ctx) { + ctx.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(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context = { + project_id: ctx.project_id, + first_name: undefined, + last_name: (ctx.last_name = 'Adams'), + email: (ctx.email = 'joe@example.com'), + user_id: (ctx.user_id = 'user-id-123'), + } + + ctx.populatedCursorData = { + doc_id: ctx.doc_id, + id: ctx.client.publicId, + name: `${ctx.last_name}`, + row: ctx.row, + column: ctx.column, + email: ctx.email, + user_id: ctx.user_id, + } + ctx.WebsocketController.updateClientPosition( + ctx.client, + ctx.update, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it("should send the update to the project room with the user's name", function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith( + ctx.project_id, + 'clientTracking.clientUpdated', + ctx.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition + .calledWith( + ctx.project_id, + ctx.client.publicId, + { + _id: ctx.user_id, + email: ctx.email, + first_name: undefined, + last_name: ctx.last_name, + }, + { + row: ctx.row, + column: ctx.column, + doc_id: ctx.doc_id, + } + ) + .should.equal(true) + resolve() + }) + }) + + it('should increment the update-client-position metric at 0.1 frequency', function (ctx) { + ctx.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(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context = { + project_id: ctx.project_id, + first_name: undefined, + last_name: undefined, + email: (ctx.email = 'joe@example.com'), + user_id: (ctx.user_id = 'user-id-123'), + } + ctx.WebsocketController.updateClientPosition( + ctx.client, + ctx.update, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should send the update to the project name with no name', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith(ctx.project_id, 'clientTracking.clientUpdated', { + doc_id: ctx.doc_id, + id: ctx.client.publicId, + user_id: ctx.user_id, + name: '', + row: ctx.row, + column: ctx.column, + email: ctx.email, + }) + .should.equal(true) + }) + }) + + describe('with an anonymous user', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.ol_context = { + project_id: ctx.project_id, + } + ctx.WebsocketController.updateClientPosition( + ctx.client, + ctx.update, + err => { + if (err) return reject(err) + resolve() + } + ) + }) + }) + + it('should send the update to the project room with no name', function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith(ctx.project_id, 'clientTracking.clientUpdated', { + doc_id: ctx.doc_id, + id: ctx.client.publicId, + name: '', + row: ctx.row, + column: ctx.column, + }) + .should.equal(true) + }) + + it('should not send cursor data to the connected user manager', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.ConnectedUsersManager.updateUserPosition.called.should.equal( + false + ) + resolve() + }) + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.disconnected = true + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = + sinon.stub() + ctx.WebsocketController.updateClientPosition( + ctx.client, + ctx.update, + (...args) => { + ctx.callback(...args) + if (args[0]) return reject(args[0]) + resolve() + } + ) + }) + }) + + it('should call the callback with no details', function (ctx) { + expect(ctx.callback.args[0]).to.deep.equal([]) + }) + + it('should not check permissions', function (ctx) { + expect( + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.called + ).to.equal(false) + }) + }) + }) + + describe('applyOtUpdate', function () { + beforeEach(function (ctx) { + ctx.update = { op: { p: 12, t: 'foo' } } + ctx.client.ol_context.user_id = ctx.user_id + ctx.client.ol_context.project_id = ctx.project_id + ctx.WebsocketController._assertClientCanApplyUpdate = sinon + .stub() + .yields() + ctx.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3) + }) + + describe('succesfully', function () { + beforeEach(function (ctx) { + ctx.WebsocketController.applyOtUpdate( + ctx.client, + ctx.doc_id, + ctx.update, + ctx.callback + ) + }) + + it('should set the source of the update to the client id', function (ctx) { + ctx.update.meta.source.should.equal(ctx.client.publicId) + }) + + it('should set the user_id of the update to the user id', function (ctx) { + ctx.update.meta.user_id.should.equal(ctx.user_id) + }) + + it('should queue the update', function (ctx) { + ctx.DocumentUpdaterManager.queueChange + .calledWith(ctx.project_id, ctx.doc_id, ctx.update) + .should.equal(true) + }) + + it('should call the callback', function (ctx) { + ctx.callback.called.should.equal(true) + }) + + it('should increment the doc updates', function (ctx) { + ctx.metrics.inc.calledWith('editor.doc-update').should.equal(true) + }) + }) + + describe('unsuccessfully', function () { + beforeEach(function (ctx) { + ctx.client.disconnect = sinon.stub() + ctx.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArgWith(3, (ctx.error = new Error('Something went wrong'))) + ctx.WebsocketController.applyOtUpdate( + ctx.client, + ctx.doc_id, + ctx.update, + ctx.callback + ) + }) + + it('should disconnect the client', function (ctx) { + ctx.client.disconnect.called.should.equal(true) + }) + + it('should not log an error', function (ctx) { + ctx.logger.error.called.should.equal(false) + }) + + it('should call the callback with the error', function (ctx) { + ctx.callback.calledWith(ctx.error).should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function (ctx) { + ctx.client.disconnect = sinon.stub() + ctx.WebsocketController._assertClientCanApplyUpdate = sinon + .stub() + .yields((ctx.error = new Error('not authorized'))) + ctx.WebsocketController.applyOtUpdate( + ctx.client, + ctx.doc_id, + ctx.update, + ctx.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 not log a warning', function (ctx) { + ctx.logger.warn.called.should.equal(false) + }) + + it('should call the callback with the error', function (ctx) { + ctx.callback.calledWith(ctx.error).should.equal(true) + }) + }) + + describe('update_too_large', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.disconnect = sinon.stub() + ctx.client.emit = sinon.stub() + ctx.client.ol_context.user_id = ctx.user_id + ctx.client.ol_context.project_id = ctx.project_id + const error = new UpdateTooLargeError(7372835) + ctx.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArgWith(3, error) + ctx.WebsocketController.applyOtUpdate( + ctx.client, + ctx.doc_id, + ctx.update, + ctx.callback + ) + setTimeout(() => resolve(), 1) + }) + }) + + it('should call the callback with no error', function (ctx) { + ctx.callback.called.should.equal(true) + ctx.callback.args[0].should.deep.equal([]) + }) + + it('should log a warning with the size and context', function (ctx) { + ctx.logger.warn.called.should.equal(true) + ctx.logger.warn.args[0].should.deep.equal([ + { + userId: ctx.user_id, + projectId: ctx.project_id, + docId: ctx.doc_id, + updateSize: 7372835, + }, + 'update is too large', + ]) + }) + + describe('after 100ms', function () { + beforeEach(async function () { + await new Promise(resolve => { + setTimeout(resolve, 100) + }) + }) + + it('should send an otUpdateError the client', function (ctx) { + ctx.client.emit.calledWith('otUpdateError').should.equal(true) + }) + + it('should disconnect the client', function (ctx) { + ctx.client.disconnect.called.should.equal(true) + }) + }) + + describe('when the client disconnects during the next 100ms', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.client.disconnected = true + setTimeout(resolve, 100) + }) + }) + + it('should not send an otUpdateError the client', function (ctx) { + ctx.client.emit.calledWith('otUpdateError').should.equal(false) + }) + + it('should not disconnect the client', function (ctx) { + ctx.client.disconnect.called.should.equal(false) + }) + + it('should increment the editor.doc-update.disconnected metric with a status', function (ctx) { + expect( + ctx.metrics.inc.calledWith('editor.doc-update.disconnected', 1, { + status: 'at-otUpdateError', + }) + ).to.equal(true) + }) + }) + }) + }) + + describe('_assertClientCanApplyUpdate', function () { + beforeEach(function (ctx) { + ctx.edit_update = { + op: [ + { i: 'foo', p: 42 }, + { c: 'bar', p: 132 }, + ], + } // comments may still be in an edit op + ctx.comment_update = { op: [{ c: 'bar', p: 132 }] } + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub() + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc = sinon.stub() + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub() + }) + + describe('with a read-write client', function () { + it('should return successfully', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null) + ctx.WebsocketController._assertClientCanApplyUpdate( + ctx.client, + ctx.doc_id, + ctx.edit_update, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + }) + + describe('with a read-only client and an edit op', function () { + it('should return an error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + ctx.WebsocketController._assertClientCanApplyUpdate( + ctx.client, + ctx.doc_id, + ctx.edit_update, + error => { + expect(error.message).to.equal('not authorized') + resolve() + } + ) + }) + }) + }) + + describe('with a read-only client and a comment op', function () { + it('should return successfully', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + ctx.WebsocketController._assertClientCanApplyUpdate( + ctx.client, + ctx.doc_id, + ctx.comment_update, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + }) + + describe('with a totally unauthorized client', function () { + it('should return an error', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error('not authorized') + ) + ctx.WebsocketController._assertClientCanApplyUpdate( + ctx.client, + ctx.doc_id, + ctx.comment_update, + error => { + expect(error.message).to.equal('not authorized') + resolve() + } + ) + }) + }) + }) + + describe('with a review client', function () { + it('op with tc should succeed', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields( + null + ) + ctx.WebsocketController._assertClientCanApplyUpdate( + ctx.client, + ctx.doc_id, + { op: [{ p: 10, i: 'a' }], meta: { tc: '123456' } }, + error => { + expect(error).to.be.null + resolve() + } + ) + }) + }) + + it('op without tc should fail', async function (ctx) { + await new Promise((resolve, reject) => { + ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields( + null + ) + ctx.WebsocketController._assertClientCanApplyUpdate( + ctx.client, + ctx.doc_id, + { op: [{ p: 10, i: 'a' }] }, + error => { + expect(error.message).to.equal('not authorized') + resolve() + } + ) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebsocketControllerTests.js b/services/real-time/test/unit/js/WebsocketControllerTests.js deleted file mode 100644 index 1f3ca67724..0000000000 --- a/services/real-time/test/unit/js/WebsocketControllerTests.js +++ /dev/null @@ -1,1698 +0,0 @@ -/* eslint-disable - 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 sinon = require('sinon') -const { expect } = require('chai') -const modulePath = '../../../app/js/WebsocketController.js' -const SandboxedModule = require('sandboxed-module') -const tk = require('timekeeper') -const { UpdateTooLargeError } = require('../../../app/js/Errors') - -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: {}, - joinLeaveEpoch: 0, - 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 = {}), - '@overleaf/metrics': (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() - .callsArgAsync(4) - this.isRestrictedUser = true - this.isTokenMember = true - this.isInvitedMember = true - this.WebApiManager.joinProject = sinon - .stub() - .callsArgWith(2, null, this.project, this.privilegeLevel, { - isRestrictedUser: this.isRestrictedUser, - isTokenMember: this.isTokenMember, - isInvitedMember: this.isInvitedMember, - }) - 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 set the is_token_member flag on the client', function () { - this.client.ol_context.is_token_member.should.equal(this.isTokenMember) - }) - it('should set the is_invited_member flag on the client', function () { - this.client.ol_context.is_invited_member.should.equal( - this.isInvitedMember - ) - }) - 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() - .callsArgAsync(4) - this.isRestrictedUser = true - this.isTokenMember = true - this.isInvitedMember = true - this.WebApiManager.joinProject = sinon - .stub() - .callsArgWith(2, null, this.project, this.privilegeLevel, { - isRestrictedUser: this.isRestrictedUser, - isTokenMember: this.isTokenMember, - isInvitedMember: this.isInvitedMember, - }) - 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, { - isRestrictedUser: this.isRestrictedUser, - isTokenMember: this.isTokenMember, - isInvitedMember: this.isInvitedMember, - }) - } - - 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: roomId => { - if (roomId !== 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 (done) { - this.clientsInRoom = ['mock-remaining-client'] - this.io = { - sockets: { - clients: roomId => { - if (roomId !== this.project_id) { - throw 'expected room_id to be project_id' - } - return this.clientsInRoom - }, - }, - } - return this.WebsocketController.leaveProject(this.io, this.client, done) - }) - - 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().yields() - this.AuthorizationManager.assertClientCanViewProject = sinon - .stub() - .callsArgWith(1, null) - this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon - .stub() - .callsArgWith(2, 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 inc the joinLeaveEpoch', function () { - expect(this.client.joinLeaveEpoch).to.equal(1) - }) - - 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 escapedLines = this.callback.args[0][1] - const escapedWord = escapedLines.pop() - escapedWord.should.equal('räksmörgÃ¥s') - // Check that unescaping works - return decodeURIComponent(escape(escapedWord)).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 encodedComments = this.callback.args[0][4] - const encodedComment = encodedComments.comments.pop() - const encodedCommentText = encodedComment.op.c - return encodedCommentText.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 encodedChanges = this.callback.args[0][4] - const encodedChange = encodedChanges.changes.pop() - const encodedChangeText = encodedChange.op.i - return encodedChangeText.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 encodedChanges = this.callback.args[0][4] - const encodedChange = encodedChanges.changes.pop() - const encodedChangeText = encodedChange.op.d - return encodedChangeText.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 auth checks are running', function () { - beforeEach(function (done) { - this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( - new Error() - ) - this.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => { - this.client.disconnected = true - cb() - } - - this.WebsocketController.joinDoc( - this.client, - this.doc_id, - -1, - this.options, - (...args) => { - this.callback(...args) - done() - } - ) - }) - - it('should call the callback with no details', function () { - expect(this.callback.called).to.equal(true) - expect(this.callback.args[0]).to.deep.equal([]) - }) - - it('should increment the editor.join-doc.disconnected metric with a status', function () { - expect( - this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { - status: 'after-client-auth-check', - }) - ).to.equal(true) - }) - - it('should not get the document', function () { - expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false) - }) - }) - - describe('when the client starts a parallel joinDoc request', function () { - beforeEach(function (done) { - this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( - new Error() - ) - this.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => { - this.DocumentUpdaterManager.checkDocument = sinon.stub().yields() - this.WebsocketController.joinDoc( - this.client, - this.doc_id, - -1, - {}, - () => {} - ) - cb() - } - - this.WebsocketController.joinDoc( - this.client, - this.doc_id, - -1, - this.options, - (...args) => { - this.callback(...args) - // make sure the other joinDoc request completed - setTimeout(done, 5) - } - ) - }) - - it('should call the callback with an error', function () { - expect(this.callback.called).to.equal(true) - expect(this.callback.args[0][0].message).to.equal( - 'joinLeaveEpoch mismatch' - ) - }) - - it('should get the document once (the parallel request wins)', function () { - expect(this.DocumentUpdaterManager.getDocument.callCount).to.equal(1) - }) - }) - - describe('when the client starts a parallel leaveDoc request', function () { - beforeEach(function (done) { - this.RoomManager.leaveDoc = sinon.stub() - - this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( - new Error() - ) - this.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => { - this.WebsocketController.leaveDoc(this.client, this.doc_id, () => {}) - cb() - } - - this.WebsocketController.joinDoc( - this.client, - this.doc_id, - -1, - this.options, - (...args) => { - this.callback(...args) - done() - } - ) - }) - - it('should call the callback with an error', function () { - expect(this.callback.called).to.equal(true) - expect(this.callback.args[0][0].message).to.equal( - 'joinLeaveEpoch mismatch' - ) - }) - - it('should not get the document', function () { - 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, docId, 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 = ( - projectId, - docId, - 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 inc the joinLeaveEpoch', function () { - expect(this.client.joinLeaveEpoch).to.equal(1) - }) - - 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() - .callsArgAsync(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 (done) { - 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.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, - } - this.WebsocketController.updateClientPosition( - this.client, - this.update, - done - ) - }) - - 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 (done) { - 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.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, - } - this.WebsocketController.updateClientPosition( - this.client, - this.update, - done - ) - }) - - 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 (done) { - 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.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, - } - this.WebsocketController.updateClientPosition( - this.client, - this.update, - done - ) - }) - - 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 (done) { - 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, - done - ) - }) - - 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 (done) { - this.client.ol_context = { - project_id: this.project_id, - } - return this.WebsocketController.updateClientPosition( - this.client, - this.update, - done - ) - }) - - 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 (done) { - this.client.disconnected = true - this.AuthorizationManager.assertClientCanViewProjectAndDoc = - sinon.stub() - return this.WebsocketController.updateClientPosition( - this.client, - this.update, - (...args) => { - this.callback(...args) - done(args[0]) - } - ) - }) - - 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 not log an error', function () { - return this.logger.error.called.should.equal(false) - }) - - 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 not log a warning', function () { - return this.logger.warn.called.should.equal(false) - }) - - 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 UpdateTooLargeError(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([ - { - userId: this.user_id, - projectId: this.project_id, - docId: 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() - this.AuthorizationManager.assertClientCanReviewProjectAndDoc = - 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() - } - ) - }) - }) - - 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() - } - ) - }) - }) - - describe('with a review client', function () { - it('op with tc should succeed', function (done) { - this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( - new Error('not authorized') - ) - this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) - this.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields( - null - ) - return this.WebsocketController._assertClientCanApplyUpdate( - this.client, - this.doc_id, - { op: [{ p: 10, i: 'a' }], meta: { tc: '123456' } }, - error => { - expect(error).to.be.null - return done() - } - ) - }) - - return it('op without tc should fail', function (done) { - this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( - new Error('not authorized') - ) - this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) - this.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields( - null - ) - return this.WebsocketController._assertClientCanApplyUpdate( - this.client, - this.doc_id, - { op: [{ p: 10, i: 'a' }] }, - 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/WebsocketLoadBalancer.test.js similarity index 52% rename from services/real-time/test/unit/js/WebsocketLoadBalancerTests.js rename to services/real-time/test/unit/js/WebsocketLoadBalancer.test.js index 574ab6528a..ca291ad513 100644 --- a/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js +++ b/services/real-time/test/unit/js/WebsocketLoadBalancer.test.js @@ -1,62 +1,75 @@ -/* eslint-disable - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const expect = require('chai').expect -const modulePath = require('node:path').join( - __dirname, +import { vi, expect, describe, beforeEach, it } from 'vitest' + +import sinon from 'sinon' +import path from 'node:path' + +const modulePath = path.join( + import.meta.dirname, '../../../app/js/WebsocketLoadBalancer' ) describe('WebsocketLoadBalancer', function () { - beforeEach(function () { - this.rclient = {} - this.RoomEvents = { on: sinon.stub() } - this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': (this.Settings = { redis: {} }), - './RedisClientManager': { - createClientList: () => [], - }, - './SafeJsonParse': (this.SafeJsonParse = { - parse: (data, cb) => cb(null, JSON.parse(data)), - }), - './EventLogger': { checkEventOrder: sinon.stub() }, - './HealthCheckManager': { check: sinon.stub() }, - './RoomManager': (this.RoomManager = { - eventSource: sinon.stub().returns(this.RoomEvents), - }), - './ChannelManager': (this.ChannelManager = { publish: sinon.stub() }), - './ConnectedUsersManager': (this.ConnectedUsersManager = { - refreshClient: sinon.stub(), - }), + beforeEach(async function (ctx) { + ctx.rclient = {} + ctx.RoomEvents = { on: sinon.stub() } + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.Settings = { redis: {} }), + })) + + vi.doMock('./RedisClientManager', () => ({ + default: { + createClientList: () => [], }, - }) - this.io = {} - this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }] - this.WebsocketLoadBalancer.rclientSubList = [ + })) + + vi.doMock('../../../app/js/SafeJsonParse', () => ({ + default: (ctx.SafeJsonParse = { + parse: (data, cb) => cb(null, JSON.parse(data)), + }), + })) + + vi.doMock('../../../app/js/EventLogger', () => ({ + default: { checkEventOrder: sinon.stub() }, + })) + + vi.doMock('../../../app/js/HealthCheckManager', () => ({ + default: { check: sinon.stub() }, + })) + + vi.doMock('../../../app/js/RoomManager', () => ({ + default: (ctx.RoomManager = { + eventSource: sinon.stub().returns(ctx.RoomEvents), + }), + })) + + vi.doMock('../../../app/js/ChannelManager', () => ({ + default: (ctx.ChannelManager = { publish: sinon.stub() }), + })) + + vi.doMock('../../../app/js/ConnectedUsersManager', () => ({ + default: (ctx.ConnectedUsersManager = { + refreshClient: sinon.stub(), + }), + })) + + ctx.WebsocketLoadBalancer = (await import(modulePath)).default + ctx.io = {} + ctx.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }] + ctx.WebsocketLoadBalancer.rclientSubList = [ { subscribe: sinon.stub(), on: sinon.stub(), }, ] - this.room_id = 'room-id' - this.message = 'otUpdateApplied' - return (this.payload = ['argument one', 42]) + ctx.room_id = 'room-id' + ctx.message = 'otUpdateApplied' + ctx.payload = ['argument one', 42] }) describe('shouldDisconnectClient', function () { - it('should return false for general messages', function () { + it('should return false for general messages', function (ctx) { const client = { ol_context: { user_id: 'abcd' }, } @@ -65,7 +78,7 @@ describe('WebsocketLoadBalancer', function () { payload: [{ data: 'whatever' }], } expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(false) }) @@ -74,7 +87,7 @@ describe('WebsocketLoadBalancer', function () { const client = { ol_context: { user_id: 'abcd' }, } - it('should return true if the user id matches', function () { + it('should return true if the user id matches', function (ctx) { const message = { message: messageName, payload: [ @@ -84,10 +97,10 @@ describe('WebsocketLoadBalancer', function () { ], } expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(true) }) - it('should return false if the user id does not match', function () { + it('should return false if the user id does not match', function (ctx) { const message = { message: messageName, payload: [ @@ -97,7 +110,7 @@ describe('WebsocketLoadBalancer', function () { ], } expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(false) }) }) @@ -107,23 +120,23 @@ describe('WebsocketLoadBalancer', function () { const client = { ol_context: { user_id: 'abcd' }, } - it('should return false, when the user_id does not match', function () { + it('should return false, when the user_id does not match', function (ctx) { const message = { message: messageName, payload: ['xyz'], } expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(false) }) - it('should return true, if the user_id matches', function () { + it('should return true, if the user_id matches', function (ctx) { const message = { message: messageName, payload: [`${client.ol_context.user_id}`], } expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(true) }) }) @@ -143,9 +156,9 @@ describe('WebsocketLoadBalancer', function () { }, } - it('should return false', function () { + it('should return false', function (ctx) { expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(false) }) }) @@ -157,9 +170,9 @@ describe('WebsocketLoadBalancer', function () { }, } - it('should return true', function () { + it('should return true', function (ctx) { expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(true) }) }) @@ -178,9 +191,9 @@ describe('WebsocketLoadBalancer', function () { }, } - it('should return false', function () { + it('should return false', function (ctx) { expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(false) }) }) @@ -192,9 +205,9 @@ describe('WebsocketLoadBalancer', function () { }, } - it('should return false', function () { + it('should return false', function (ctx) { expect( - this.WebsocketLoadBalancer.shouldDisconnectClient(client, message) + ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message) ).to.equal(false) }) }) @@ -203,24 +216,24 @@ describe('WebsocketLoadBalancer', function () { }) describe('emitToRoom', function () { - beforeEach(function () { - return this.WebsocketLoadBalancer.emitToRoom( - this.room_id, - this.message, - ...Array.from(this.payload) + beforeEach(function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom( + ctx.room_id, + ctx.message, + ...Array.from(ctx.payload) ) }) - return it('should publish the message to redis', function () { - return this.ChannelManager.publish + it('should publish the message to redis', function (ctx) { + ctx.ChannelManager.publish .calledWith( - this.WebsocketLoadBalancer.rclientPubList[0], + ctx.WebsocketLoadBalancer.rclientPubList[0], 'editor-events', - this.room_id, + ctx.room_id, JSON.stringify({ - room_id: this.room_id, - message: this.message, - payload: this.payload, + room_id: ctx.room_id, + message: ctx.message, + payload: ctx.payload, }) ) .should.equal(true) @@ -228,224 +241,224 @@ describe('WebsocketLoadBalancer', function () { }) describe('emitToAll', function () { - beforeEach(function () { - this.WebsocketLoadBalancer.emitToRoom = sinon.stub() - return this.WebsocketLoadBalancer.emitToAll( - this.message, - ...Array.from(this.payload) + beforeEach(function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub() + ctx.WebsocketLoadBalancer.emitToAll( + ctx.message, + ...Array.from(ctx.payload) ) }) - return it("should emit to the room 'all'", function () { - return this.WebsocketLoadBalancer.emitToRoom - .calledWith('all', this.message, ...Array.from(this.payload)) + it("should emit to the room 'all'", function (ctx) { + ctx.WebsocketLoadBalancer.emitToRoom + .calledWith('all', ctx.message, ...Array.from(ctx.payload)) .should.equal(true) }) }) describe('listenForEditorEvents', function () { - beforeEach(function () { - this.WebsocketLoadBalancer._processEditorEvent = sinon.stub() - return this.WebsocketLoadBalancer.listenForEditorEvents() + beforeEach(function (ctx) { + ctx.WebsocketLoadBalancer._processEditorEvent = sinon.stub() + ctx.WebsocketLoadBalancer.listenForEditorEvents() }) - it('should subscribe to the editor-events channel', function () { - return this.WebsocketLoadBalancer.rclientSubList[0].subscribe + it('should subscribe to the editor-events channel', function (ctx) { + ctx.WebsocketLoadBalancer.rclientSubList[0].subscribe .calledWith('editor-events') .should.equal(true) }) - return it('should process the events with _processEditorEvent', function () { - return this.WebsocketLoadBalancer.rclientSubList[0].on + it('should process the events with _processEditorEvent', function (ctx) { + ctx.WebsocketLoadBalancer.rclientSubList[0].on .calledWith('message', sinon.match.func) .should.equal(true) }) }) - return describe('_processEditorEvent', function () { + describe('_processEditorEvent', function () { describe('with bad JSON', function () { - beforeEach(function () { - this.isRestrictedUser = false - this.SafeJsonParse.parse = sinon + beforeEach(function (ctx) { + ctx.isRestrictedUser = false + ctx.SafeJsonParse.parse = sinon .stub() .callsArgWith(1, new Error('oops')) - return this.WebsocketLoadBalancer._processEditorEvent( - this.io, + ctx.WebsocketLoadBalancer._processEditorEvent( + ctx.io, 'editor-events', 'blah' ) }) - return it('should log an error', function () { - return this.logger.error.called.should.equal(true) + it('should log an error', function (ctx) { + ctx.logger.error.called.should.equal(true) }) }) describe('with a designated room', function () { - beforeEach(function () { - this.io.sockets = { + beforeEach(function (ctx) { + ctx.io.sockets = { clients: sinon.stub().returns([ { id: 'client-id-1', - emit: (this.emit1 = sinon.stub()), + emit: (ctx.emit1 = sinon.stub()), ol_context: {}, }, { id: 'client-id-2', - emit: (this.emit2 = sinon.stub()), + emit: (ctx.emit2 = sinon.stub()), ol_context: {}, }, { id: 'client-id-1', - emit: (this.emit3 = sinon.stub()), + emit: (ctx.emit3 = sinon.stub()), ol_context: {}, }, // duplicate client ]), } const data = JSON.stringify({ - room_id: this.room_id, - message: this.message, - payload: this.payload, + room_id: ctx.room_id, + message: ctx.message, + payload: ctx.payload, }) - return this.WebsocketLoadBalancer._processEditorEvent( - this.io, + ctx.WebsocketLoadBalancer._processEditorEvent( + ctx.io, 'editor-events', data ) }) - return it('should send the message to all (unique) clients in the room', function () { - this.io.sockets.clients.calledWith(this.room_id).should.equal(true) - this.emit1 - .calledWith(this.message, ...Array.from(this.payload)) + it('should send the message to all (unique) clients in the room', function (ctx) { + ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true) + ctx.emit1 + .calledWith(ctx.message, ...Array.from(ctx.payload)) .should.equal(true) - this.emit2 - .calledWith(this.message, ...Array.from(this.payload)) + ctx.emit2 + .calledWith(ctx.message, ...Array.from(ctx.payload)) .should.equal(true) - return this.emit3.called.should.equal(false) + ctx.emit3.called.should.equal(false) }) }) // duplicate client should be ignored describe('with a designated room, and restricted clients, not restricted message', function () { - beforeEach(function () { - this.io.sockets = { + beforeEach(function (ctx) { + ctx.io.sockets = { clients: sinon.stub().returns([ { id: 'client-id-1', - emit: (this.emit1 = sinon.stub()), + emit: (ctx.emit1 = sinon.stub()), ol_context: {}, }, { id: 'client-id-2', - emit: (this.emit2 = sinon.stub()), + emit: (ctx.emit2 = sinon.stub()), ol_context: {}, }, { id: 'client-id-1', - emit: (this.emit3 = sinon.stub()), + emit: (ctx.emit3 = sinon.stub()), ol_context: {}, }, // duplicate client { id: 'client-id-4', - emit: (this.emit4 = sinon.stub()), + emit: (ctx.emit4 = sinon.stub()), ol_context: { is_restricted_user: true }, }, ]), } const data = JSON.stringify({ - room_id: this.room_id, - message: this.message, - payload: this.payload, + room_id: ctx.room_id, + message: ctx.message, + payload: ctx.payload, }) - return this.WebsocketLoadBalancer._processEditorEvent( - this.io, + ctx.WebsocketLoadBalancer._processEditorEvent( + ctx.io, 'editor-events', data ) }) - return it('should send the message to all (unique) clients in the room', function () { - this.io.sockets.clients.calledWith(this.room_id).should.equal(true) - this.emit1 - .calledWith(this.message, ...Array.from(this.payload)) + it('should send the message to all (unique) clients in the room', function (ctx) { + ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true) + ctx.emit1 + .calledWith(ctx.message, ...Array.from(ctx.payload)) .should.equal(true) - this.emit2 - .calledWith(this.message, ...Array.from(this.payload)) + ctx.emit2 + .calledWith(ctx.message, ...Array.from(ctx.payload)) .should.equal(true) - this.emit3.called.should.equal(false) // duplicate client should be ignored - return this.emit4.called.should.equal(true) + ctx.emit3.called.should.equal(false) // duplicate client should be ignored + ctx.emit4.called.should.equal(true) }) }) // restricted client, but should be called describe('with a designated room, and restricted clients, restricted message', function () { - beforeEach(function () { - this.io.sockets = { + beforeEach(function (ctx) { + ctx.io.sockets = { clients: sinon.stub().returns([ { id: 'client-id-1', - emit: (this.emit1 = sinon.stub()), + emit: (ctx.emit1 = sinon.stub()), ol_context: {}, }, { id: 'client-id-2', - emit: (this.emit2 = sinon.stub()), + emit: (ctx.emit2 = sinon.stub()), ol_context: {}, }, { id: 'client-id-1', - emit: (this.emit3 = sinon.stub()), + emit: (ctx.emit3 = sinon.stub()), ol_context: {}, }, // duplicate client { id: 'client-id-4', - emit: (this.emit4 = sinon.stub()), + emit: (ctx.emit4 = sinon.stub()), ol_context: { is_restricted_user: true }, }, ]), } const data = JSON.stringify({ - room_id: this.room_id, - message: (this.restrictedMessage = 'new-comment'), - payload: this.payload, + room_id: ctx.room_id, + message: (ctx.restrictedMessage = 'new-comment'), + payload: ctx.payload, }) - return this.WebsocketLoadBalancer._processEditorEvent( - this.io, + ctx.WebsocketLoadBalancer._processEditorEvent( + ctx.io, 'editor-events', data ) }) - return it('should send the message to all (unique) clients in the room, who are not restricted', function () { - this.io.sockets.clients.calledWith(this.room_id).should.equal(true) - this.emit1 - .calledWith(this.restrictedMessage, ...Array.from(this.payload)) + it('should send the message to all (unique) clients in the room, who are not restricted', function (ctx) { + ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true) + ctx.emit1 + .calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload)) .should.equal(true) - this.emit2 - .calledWith(this.restrictedMessage, ...Array.from(this.payload)) + ctx.emit2 + .calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload)) .should.equal(true) - this.emit3.called.should.equal(false) // duplicate client should be ignored - return this.emit4.called.should.equal(false) + ctx.emit3.called.should.equal(false) // duplicate client should be ignored + ctx.emit4.called.should.equal(false) }) }) // restricted client, should not be called describe('when emitting to all', function () { - beforeEach(function () { - this.io.sockets = { emit: (this.emit = sinon.stub()) } + beforeEach(function (ctx) { + ctx.io.sockets = { emit: (ctx.emit = sinon.stub()) } const data = JSON.stringify({ room_id: 'all', - message: this.message, - payload: this.payload, + message: ctx.message, + payload: ctx.payload, }) - return this.WebsocketLoadBalancer._processEditorEvent( - this.io, + ctx.WebsocketLoadBalancer._processEditorEvent( + ctx.io, 'editor-events', data ) }) - return it('should send the message to all clients', function () { - return this.emit - .calledWith(this.message, ...Array.from(this.payload)) + it('should send the message to all clients', function (ctx) { + ctx.emit + .calledWith(ctx.message, ...Array.from(ctx.payload)) .should.equal(true) }) }) @@ -474,24 +487,24 @@ describe('WebsocketLoadBalancer', function () { disconnect: sinon.stub(), }, ] - beforeEach(function () { - this.io.sockets = { + beforeEach(function (ctx) { + ctx.io.sockets = { clients: sinon.stub().returns(clients), } const data = JSON.stringify({ - room_id: this.room_id, + room_id: ctx.room_id, message, payload, }) - return this.WebsocketLoadBalancer._processEditorEvent( - this.io, + ctx.WebsocketLoadBalancer._processEditorEvent( + ctx.io, 'editor-events', data ) }) - it('should disconnect the matching client, while sending message to other clients', function () { - this.io.sockets.clients.calledWith(this.room_id).should.equal(true) + it('should disconnect the matching client, while sending message to other clients', function (ctx) { + ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true) const [client1, client2, client3] = clients diff --git a/services/real-time/test/unit/js/helpers/MockClient.js b/services/real-time/test/unit/js/helpers/MockClient.js index 61cde89ba9..bf5b92bfd3 100644 --- a/services/real-time/test/unit/js/helpers/MockClient.js +++ b/services/real-time/test/unit/js/helpers/MockClient.js @@ -3,12 +3,12 @@ */ // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. +import sinon from 'sinon' let MockClient -const sinon = require('sinon') let idCounter = 0 -module.exports = MockClient = class MockClient { +export default MockClient = class MockClient { constructor() { this.ol_context = {} this.join = sinon.stub() diff --git a/services/real-time/test/unit/setup.js b/services/real-time/test/unit/setup.js new file mode 100644 index 0000000000..0c7b2d4d4a --- /dev/null +++ b/services/real-time/test/unit/setup.js @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, chai, vi } from 'vitest' +import sinon from 'sinon' +import chaiAsPromised from 'chai-as-promised' +import sinonChai from 'sinon-chai' + +// Chai configuration +chai.should() +chai.use(chaiAsPromised) +chai.use(sinonChai) + +// Global stubs +const sandbox = sinon.createSandbox() +const stubs = { + logger: { + debug: sandbox.stub(), + log: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + err: sandbox.stub(), + error: sandbox.stub(), + }, +} + +// Mocha hooks +beforeEach(ctx => { + ctx.logger = stubs.logger + vi.doMock('@overleaf/logger', () => ({ default: ctx.logger })) +}) + +afterEach(() => { + sandbox.reset() + vi.restoreAllMocks() + vi.resetModules() +}) diff --git a/services/real-time/tsconfig.json b/services/real-time/tsconfig.json index c018d6e682..64bc0e874a 100644 --- a/services/real-time/tsconfig.json +++ b/services/real-time/tsconfig.json @@ -8,6 +8,7 @@ "config/**/*", "scripts/**/*", "test/**/*", - "types" + "types", + "vitest.config.unit.cjs" ] } diff --git a/services/real-time/vitest.config.unit.cjs b/services/real-time/vitest.config.unit.cjs new file mode 100644 index 0000000000..c9b162b45b --- /dev/null +++ b/services/real-time/vitest.config.unit.cjs @@ -0,0 +1,25 @@ +const { defineConfig } = require('vitest/config') + +let reporterOptions = {} +if (process.env.CI) { + reporterOptions = { + reporters: [ + 'default', + [ + 'junit', + { + classnameTemplate: `Unit tests.{filename}`, + }, + ], + ], + outputFile: 'reports/junit-vitest-unit.xml', + } +} +module.exports = defineConfig({ + test: { + include: ['test/unit/js/**/*.test.{js,ts}'], + setupFiles: ['./test/unit/setup.js'], + isolate: false, + ...reporterOptions, + }, +})