From c0ff9756a8dce0bf3f22eb117030b95511c054cd Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 31 May 2022 10:43:46 +0100 Subject: [PATCH 01/41] Update renovate.json (#2419) --- renovate.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index d0610966a2f..a8015ea2bf6 100644 --- a/renovate.json +++ b/renovate.json @@ -9,5 +9,8 @@ "packageRules": [{ "matchFiles": ["package.json"], "rangeStrategy": "update-lockfile" - }] + }], + "platformAutomerge": true, + "automerge": true, + "automergeType": "pr" } From 142c285063a7a89e0c00ccbd95aa3abb8d10f62b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 31 May 2022 09:52:52 +0000 Subject: [PATCH 02/41] Lock file maintenance (#2413) Co-authored-by: Renovate Bot --- yarn.lock | 441 ++++++++++++++++++++++++------------------------------ 1 file changed, 192 insertions(+), 249 deletions(-) diff --git a/yarn.lock b/yarn.lock index 243dcf3d2f5..1251583cbd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -99,16 +99,7 @@ dependencies: eslint-rule-composer "^0.3.0" -"@babel/generator@^7.12.11": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.17.12.tgz#5970e6160e9be0428e02f4aba62d8551ec366cc8" - integrity sha512-V49KtZiiiLjH/CnIW6OjJdrenrGoyh6AmKQ3k2AZFKozC1h846Q4NYlZ5nqAigPDUXfGzC88+LOUuG8yKd2kCw== - dependencies: - "@babel/types" "^7.17.12" - "@jridgewell/gen-mapping" "^0.3.0" - jsesc "^2.5.1" - -"@babel/generator@^7.17.12", "@babel/generator@^7.18.2", "@babel/generator@^7.7.2": +"@babel/generator@^7.12.11", "@babel/generator@^7.18.2", "@babel/generator@^7.7.2": version "7.18.2" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.2.tgz#33873d6f89b21efe2da63fe554460f3df1c5880d" integrity sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw== @@ -253,7 +244,7 @@ "@babel/helper-wrap-function" "^7.16.8" "@babel/types" "^7.16.8" -"@babel/helper-replace-supers@^7.16.7": +"@babel/helper-replace-supers@^7.16.7", "@babel/helper-replace-supers@^7.18.2": version "7.18.2" resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.2.tgz#41fdfcc9abaf900e18ba6e5931816d9062a7b2e0" integrity sha512-XzAIyxx+vFnrOxiQrToSUOzUOn0e1J2Li40ntddek1Y69AXUTXoDJ40/D5RdjFu7s7qHiaeoTiempZcbuVXh2Q== @@ -323,15 +314,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.2.3", "@babel/parser@^7.9.4": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.12.tgz#36c2ed06944e3691ba82735fc4cf62d12d491a23" - integrity sha512-FLzHmN9V3AJIrWfOpvRlZCeVg/WLdicSnTMsLur6uDj9TT8ymUlG9XxURdW/XvuygK+2CW0poOJABdA4m/YKxA== - -"@babel/parser@^7.16.7", "@babel/parser@^7.17.12", "@babel/parser@^7.18.0": - version "7.18.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.3.tgz#39e99c7b0c4c56cef4d1eed8de9f506411c2ebc2" - integrity sha512-rL50YcEuHbbauAFAysNsJA4/f89fGTOBRNs9P81sniKnKAr4xULe5AecolcsKbi88xu0ByWYDj/S1AJ3FSFuSQ== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.16.7", "@babel/parser@^7.18.0", "@babel/parser@^7.2.3", "@babel/parser@^7.9.4": + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.4.tgz#6774231779dd700e0af29f6ad8d479582d7ce5ef" + integrity sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.17.12": version "7.17.12" @@ -627,23 +613,23 @@ "@babel/helper-plugin-utils" "^7.16.7" "@babel/plugin-transform-block-scoping@^7.17.12": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.17.12.tgz#68fc3c4b3bb7dfd809d97b7ed19a584052a2725c" - integrity sha512-jw8XW/B1i7Lqwqj2CbrViPcZijSxfguBWZP2aN59NHgxUyO/OcO1mfdCxH13QhN5LbWhPkX+f+brKGhZTiqtZQ== + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.4.tgz#7988627b3e9186a13e4d7735dc9c34a056613fb9" + integrity sha512-+Hq10ye+jlvLEogSOtq4mKvtk7qwcUQ1f0Mrueai866C82f844Yom2cttfJdMdqRLTxWpsbfbkIkOIfovyUQXw== dependencies: "@babel/helper-plugin-utils" "^7.17.12" "@babel/plugin-transform-classes@^7.17.12": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.17.12.tgz#da889e89a4d38375eeb24985218edeab93af4f29" - integrity sha512-cvO7lc7pZat6BsvH6l/EGaI8zpl8paICaoGk+7x7guvtfak/TbIf66nYmJOH13EuG0H+Xx3M+9LQDtSvZFKXKw== + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.4.tgz#51310b812a090b846c784e47087fa6457baef814" + integrity sha512-e42NSG2mlKWgxKUAD9EJJSkZxR67+wZqzNxLSpc51T8tRU5SLFHsPmgYR5yr7sdgX4u+iHA1C5VafJ6AyImV3A== dependencies: "@babel/helper-annotate-as-pure" "^7.16.7" - "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-environment-visitor" "^7.18.2" "@babel/helper-function-name" "^7.17.9" "@babel/helper-optimise-call-expression" "^7.16.7" "@babel/helper-plugin-utils" "^7.17.12" - "@babel/helper-replace-supers" "^7.16.7" + "@babel/helper-replace-supers" "^7.18.2" "@babel/helper-split-export-declaration" "^7.16.7" globals "^11.1.0" @@ -734,9 +720,9 @@ babel-plugin-dynamic-import-node "^2.3.3" "@babel/plugin-transform-modules-systemjs@^7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.0.tgz#50ecdb43de97c8483824402f7125edb94cddb09a" - integrity sha512-vwKpxdHnlM5tIrRt/eA0bzfbi7gUBLN08vLu38np1nZevlPySRe6yvuATJB5F/WPJ+ur4OXwpVYq9+BsxqAQuQ== + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.4.tgz#3d6fd9868c735cce8f38d6ae3a407fb7e61e6d46" + integrity sha512-lH2UaQaHVOAeYrUUuZ8i38o76J/FnO8vu21OE+tD1MyP9lxdZoSfz+pDbWkq46GogUrdrMz3tiz/FYGB+bVThg== dependencies: "@babel/helper-hoist-variables" "^7.16.7" "@babel/helper-module-transforms" "^7.18.0" @@ -853,11 +839,11 @@ "@babel/helper-plugin-utils" "^7.17.12" "@babel/plugin-transform-typescript@^7.17.12": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.17.12.tgz#9654587131bc776ff713218d929fa9a2e98ca16d" - integrity sha512-ICbXZqg6hgenjmwciVI/UfqZtExBrZOrS8sLB5mTHGO/j08Io3MmooULBiijWk9JBknjM3CbbtTc/0ZsqLrjXQ== + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.4.tgz#587eaf6a39edb8c06215e550dc939faeadd750bf" + integrity sha512-l4vHuSLUajptpHNEOUDEGsnpl9pfRLsN1XUoDQDD/YBuXTM+v37SHGS+c6n4jdcZy96QtuUuSvZYMLSSsjH8Mw== dependencies: - "@babel/helper-create-class-features-plugin" "^7.17.12" + "@babel/helper-create-class-features-plugin" "^7.18.0" "@babel/helper-plugin-utils" "^7.17.12" "@babel/plugin-syntax-typescript" "^7.17.12" @@ -1004,23 +990,7 @@ "@babel/parser" "^7.16.7" "@babel/types" "^7.16.7" -"@babel/traverse@^7.1.6": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.17.12.tgz#011874d2abbca0ccf1adbe38f6f7a4ff1747599c" - integrity sha512-zULPs+TbCvOkIFd4FrG53xrpxvCBwLIgo6tO0tJorY7YV2IWFxUfS/lXDJbGgfyYt9ery/Gxj2niwttNnB0gIw== - dependencies: - "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.17.12" - "@babel/helper-environment-visitor" "^7.16.7" - "@babel/helper-function-name" "^7.17.9" - "@babel/helper-hoist-variables" "^7.16.7" - "@babel/helper-split-export-declaration" "^7.16.7" - "@babel/parser" "^7.17.12" - "@babel/types" "^7.17.12" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/traverse@^7.13.0", "@babel/traverse@^7.16.8", "@babel/traverse@^7.18.0", "@babel/traverse@^7.18.2", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.1.6", "@babel/traverse@^7.13.0", "@babel/traverse@^7.16.8", "@babel/traverse@^7.18.0", "@babel/traverse@^7.18.2", "@babel/traverse@^7.7.2": version "7.18.2" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.2.tgz#b77a52604b5cc836a9e1e08dca01cba67a12d2e8" integrity sha512-9eNwoeovJ6KH9zcCNnENY7DMFwTU9JdGCFtqNLfUAqtUHRCOsTOqWoffosP8vKmNYeSBUv3yVJXjfd8ucwOjUA== @@ -1036,18 +1006,10 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3": - version "7.17.12" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.12.tgz#1210690a516489c0200f355d87619157fbbd69a0" - integrity sha512-rH8i29wcZ6x9xjzI5ILHL/yZkbQnCERdHlogKuIb4PUr7do4iT8DPekrTbBLWTnRQm6U0GYABbTMSzijmEqlAg== - dependencies: - "@babel/helper-validator-identifier" "^7.16.7" - to-fast-properties "^2.0.0" - -"@babel/types@^7.16.0", "@babel/types@^7.16.7", "@babel/types@^7.16.8", "@babel/types@^7.17.0", "@babel/types@^7.17.12", "@babel/types@^7.18.0", "@babel/types@^7.18.2", "@babel/types@^7.4.4": - version "7.18.2" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.2.tgz#191abfed79ebe6f4242f643a9a5cbaa36b10b091" - integrity sha512-0On6B8A4/+mFUto5WERt3EEuG1NznDirvwca1O8UwXQHVY8g3R7OzYgxXdOfMwLO08UrpUD/2+3Bclyq+/C94Q== +"@babel/types@^7.0.0", "@babel/types@^7.16.0", "@babel/types@^7.16.7", "@babel/types@^7.16.8", "@babel/types@^7.17.0", "@babel/types@^7.18.0", "@babel/types@^7.18.2", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.18.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.4.tgz#27eae9b9fd18e9dccc3f9d6ad051336f307be354" + integrity sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw== dependencies: "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" @@ -1321,6 +1283,14 @@ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.1.tgz#36a6acc93987adcf0ba50c66908bd0b70de8afea" integrity sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ== +"@jridgewell/source-map@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" + integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + "@jridgewell/sourcemap-codec@^1.4.10": version "1.4.13" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c" @@ -1336,6 +1306,7 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz": version "3.2.8" + uid "8d53636d045e1776e2a2ec6613e57330dd9ce856" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": @@ -1609,9 +1580,9 @@ integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== "@types/node@*": - version "17.0.34" - resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.34.tgz#3b0b6a50ff797280b8d000c6281d229f9c538cef" - integrity sha512-XImEz7XwTvDBtzlTnm8YvMqGW/ErMWBsKZ+hMTvnDIjGCKxwK5Xpc+c/oQjOauwq8M4OS11hEkpjX8rrI/eEgA== + version "17.0.36" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.36.tgz#c0d5f2fe76b47b63e0e0efc3d2049a9970d68794" + integrity sha512-V3orv+ggDsWVHP99K3JlwtH20R7J4IhI1Kksgc+64q5VxgfRkQG8Ws3MFm/FZOKDYGy9feGFlZ70/HpCNe9QaA== "@types/node@12": version "12.20.52" @@ -1619,9 +1590,9 @@ integrity sha512-cfkwWw72849SNYp3Zx0IcIs25vABmFh73xicxhCkTcvtZQeIez15PpwQN8fY3RD7gv1Wrxlc9MEtfMORZDEsGw== "@types/prettier@^2.1.5": - version "2.6.1" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.6.1.tgz#76e72d8a775eef7ce649c63c8acae1a0824bbaed" - integrity sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw== + version "2.6.3" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.6.3.tgz#68ada76827b0010d0db071f739314fa429943d0a" + integrity sha512-ymZk3LEC/fsut+/Q5qejp6R9O1rMxz3XaRHDV6kX8MrGAhOSPqVARbDi+EZvInBpw+BnCX3TD240byVkOfQsHg== "@types/request@^2.48.5": version "2.48.8" @@ -1661,13 +1632,13 @@ "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^5.6.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.26.0.tgz#c1f98ccba9d345e38992975d3ca56ed6260643c2" - integrity sha512-oGCmo0PqnRZZndr+KwvvAUvD3kNE4AfyoGCwOZpoCncSh4MVD06JTE8XQa2u9u+NX5CsyZMBTEc2C72zx38eYA== + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.0.tgz#23d82a4f21aaafd8f69dbab7e716323bb6695cc8" + integrity sha512-DDrIA7GXtmHXr1VCcx9HivA39eprYBIFxbQEHI6NyraRDxCGpxAFiYQAT/1Y0vh1C+o2vfBiy4IuPoXxtTZCAQ== dependencies: - "@typescript-eslint/scope-manager" "5.26.0" - "@typescript-eslint/type-utils" "5.26.0" - "@typescript-eslint/utils" "5.26.0" + "@typescript-eslint/scope-manager" "5.27.0" + "@typescript-eslint/type-utils" "5.27.0" + "@typescript-eslint/utils" "5.27.0" debug "^4.3.4" functional-red-black-tree "^1.0.1" ignore "^5.2.0" @@ -1676,68 +1647,68 @@ tsutils "^3.21.0" "@typescript-eslint/parser@^5.6.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.26.0.tgz#a61b14205fe2ab7533deb4d35e604add9a4ceee2" - integrity sha512-n/IzU87ttzIdnAH5vQ4BBDnLPly7rC5VnjN3m0xBG82HK6rhRxnCb3w/GyWbNDghPd+NktJqB/wl6+YkzZ5T5Q== + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.27.0.tgz#62bb091ed5cf9c7e126e80021bb563dcf36b6b12" + integrity sha512-8oGjQF46c52l7fMiPPvX4It3u3V3JipssqDfHQ2hcR0AeR8Zge+OYyKUCm5b70X72N1qXt0qgHenwN6Gc2SXZA== dependencies: - "@typescript-eslint/scope-manager" "5.26.0" - "@typescript-eslint/types" "5.26.0" - "@typescript-eslint/typescript-estree" "5.26.0" + "@typescript-eslint/scope-manager" "5.27.0" + "@typescript-eslint/types" "5.27.0" + "@typescript-eslint/typescript-estree" "5.27.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.26.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.26.0.tgz#44209c7f649d1a120f0717e0e82da856e9871339" - integrity sha512-gVzTJUESuTwiju/7NiTb4c5oqod8xt5GhMbExKsCTp6adU3mya6AGJ4Pl9xC7x2DX9UYFsjImC0mA62BCY22Iw== +"@typescript-eslint/scope-manager@5.27.0": + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.27.0.tgz#a272178f613050ed62f51f69aae1e19e870a8bbb" + integrity sha512-VnykheBQ/sHd1Vt0LJ1JLrMH1GzHO+SzX6VTXuStISIsvRiurue/eRkTqSrG0CexHQgKG8shyJfR4o5VYioB9g== dependencies: - "@typescript-eslint/types" "5.26.0" - "@typescript-eslint/visitor-keys" "5.26.0" + "@typescript-eslint/types" "5.27.0" + "@typescript-eslint/visitor-keys" "5.27.0" -"@typescript-eslint/type-utils@5.26.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.26.0.tgz#937dee97702361744a3815c58991acf078230013" - integrity sha512-7ccbUVWGLmcRDSA1+ADkDBl5fP87EJt0fnijsMFTVHXKGduYMgienC/i3QwoVhDADUAPoytgjbZbCOMj4TY55A== +"@typescript-eslint/type-utils@5.27.0": + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.27.0.tgz#36fd95f6747412251d79c795b586ba766cf0974b" + integrity sha512-vpTvRRchaf628Hb/Xzfek+85o//zEUotr1SmexKvTfs7czXfYjXVT/a5yDbpzLBX1rhbqxjDdr1Gyo0x1Fc64g== dependencies: - "@typescript-eslint/utils" "5.26.0" + "@typescript-eslint/utils" "5.27.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.26.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.26.0.tgz#cb204bb154d3c103d9cc4d225f311b08219469f3" - integrity sha512-8794JZFE1RN4XaExLWLI2oSXsVImNkl79PzTOOWt9h0UHROwJedNOD2IJyfL0NbddFllcktGIO2aOu10avQQyA== +"@typescript-eslint/types@5.27.0": + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.27.0.tgz#c3f44b9dda6177a9554f94a74745ca495ba9c001" + integrity sha512-lY6C7oGm9a/GWhmUDOs3xAVRz4ty/XKlQ2fOLr8GAIryGn0+UBOoJDWyHer3UgrHkenorwvBnphhP+zPmzmw0A== -"@typescript-eslint/typescript-estree@5.26.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.26.0.tgz#16cbceedb0011c2ed4f607255f3ee1e6e43b88c3" - integrity sha512-EyGpw6eQDsfD6jIqmXP3rU5oHScZ51tL/cZgFbFBvWuCwrIptl+oueUZzSmLtxFuSOQ9vDcJIs+279gnJkfd1w== +"@typescript-eslint/typescript-estree@5.27.0": + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.0.tgz#7965f5b553c634c5354a47dcce0b40b94611e995" + integrity sha512-QywPMFvgZ+MHSLRofLI7BDL+UczFFHyj0vF5ibeChDAJgdTV8k4xgEwF0geFhVlPc1p8r70eYewzpo6ps+9LJQ== dependencies: - "@typescript-eslint/types" "5.26.0" - "@typescript-eslint/visitor-keys" "5.26.0" + "@typescript-eslint/types" "5.27.0" + "@typescript-eslint/visitor-keys" "5.27.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.26.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.26.0.tgz#896b8480eb124096e99c8b240460bb4298afcfb4" - integrity sha512-PJFwcTq2Pt4AMOKfe3zQOdez6InIDOjUJJD3v3LyEtxHGVVRK3Vo7Dd923t/4M9hSH2q2CLvcTdxlLPjcIk3eg== +"@typescript-eslint/utils@5.27.0": + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.27.0.tgz#d0021cbf686467a6a9499bd0589e19665f9f7e71" + integrity sha512-nZvCrkIJppym7cIbP3pOwIkAefXOmfGPnCM0LQfzNaKxJHI6VjI8NC662uoiPlaf5f6ymkTy9C3NQXev2mdXmA== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.26.0" - "@typescript-eslint/types" "5.26.0" - "@typescript-eslint/typescript-estree" "5.26.0" + "@typescript-eslint/scope-manager" "5.27.0" + "@typescript-eslint/types" "5.27.0" + "@typescript-eslint/typescript-estree" "5.27.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.26.0": - version "5.26.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.26.0.tgz#7195f756e367f789c0e83035297c45b417b57f57" - integrity sha512-wei+ffqHanYDOQgg/fS6Hcar6wAWv0CUPQ3TZzOWd2BLfgP539rb49bwua8WRAs7R6kOSLn82rfEu2ro6Llt8Q== +"@typescript-eslint/visitor-keys@5.27.0": + version "5.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.0.tgz#97aa9a5d2f3df8215e6d3b77f9d214a24db269bd" + integrity sha512-46cYrteA2MrIAjv9ai44OQDUoCZyHeGIc4lsjCUX2WT6r4C+kidz1bNiR4017wHOPUythYeH+Sc7/cFP97KEAA== dependencies: - "@typescript-eslint/types" "5.26.0" + "@typescript-eslint/types" "5.27.0" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.3: @@ -1749,9 +1720,9 @@ JSONStream@^1.0.3: through ">=2.2.7 <3" ace-builds@^1.4.13: - version "1.5.0" - resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.5.0.tgz#38ad4d6a6f7b50453533ee307c877a7133c33fb1" - integrity sha512-1BtEfIhFl/VDNRS9R1m9F8Kmeh2uJ98CxTeBE0kBjJpv5S5N2buTVWtc1BGXL9AromN7ekBjaEBaUl+ZPn4ciA== + version "1.5.3" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.5.3.tgz#05f81d3464a9ea19696e5e6fd0f924d37dab442f" + integrity sha512-WN5BKR2aTSuBmisO8jo3Fytk6sOmJGki82v/Boeic81IgYN8pFHNkXq2anDF0XkmfDWMqLbRoW9sjc/GtKzQbQ== acorn-globals@^3.0.0: version "3.1.0" @@ -1765,7 +1736,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-node@^1.2.0, acorn-node@^1.3.0, acorn-node@^1.5.2, acorn-node@^1.6.1: +acorn-node@^1.2.0, acorn-node@^1.3.0, acorn-node@^1.5.2, acorn-node@^1.8.2: version "1.8.2" resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== @@ -2169,9 +2140,9 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== bn.js@^5.0.0, bn.js@^5.1.1: - version "5.2.0" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" - integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== brace-expansion@^1.1.7: version "1.1.11" @@ -2544,7 +2515,7 @@ cli-color@^2.0.0: cliui@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" - integrity sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE= + integrity sha512-GIOYRizG+TGoc7Wgc1LiOTLare95R3mzKgoln+Q/lE4ceiYH19gUpl0l0Ffq4lJDEf3FxujMe6IBfOCs7pfqNA== dependencies: center-align "^0.1.1" right-align "^0.1.1" @@ -2571,7 +2542,7 @@ clone-deep@^4.0.1: co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== collect-v8-coverage@^1.0.0: version "1.0.1" @@ -2605,7 +2576,7 @@ color-name@~1.1.4: combine-source-map@^0.8.0, combine-source-map@~0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/combine-source-map/-/combine-source-map-0.8.0.tgz#a58d0df042c186fcf822a8e8015f5450d2d79a8b" - integrity sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos= + integrity sha512-UlxQ9Vw0b/Bt/KYwCFqdEwsQ1eL8d1gibiFb7lxQJFdvTgc2hIZi6ugsg+kyhzhPV+QEpUiEIwInIAIrgoEkrg== dependencies: convert-source-map "~1.1.0" inline-source-map "~0.6.0" @@ -2632,12 +2603,12 @@ commander@^4.0.1: commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== concat-stream@^1.6.0, concat-stream@^1.6.1, concat-stream@~1.6.0: version "1.6.2" @@ -2667,7 +2638,7 @@ constantinople@^3.0.1, constantinople@^3.1.2: constants-browserify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" - integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= + integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ== content-type@^1.0.4: version "1.0.4" @@ -2684,7 +2655,7 @@ convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@~1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.1.3.tgz#4829c877e9fe49b3161f3bf3673888e204699860" - integrity sha1-SCnId+n+SbMWHzvzZziI4gRpmGA= + integrity sha512-Y8L5rp6jo+g9VEPgvqNfEopjTR4OTYct8lXlS8iVQdmnjDvbdbzYe9rjtFCB9egC86JoNCU61WRY+ScjkZpnIg== core-js-compat@^3.21.0, core-js-compat@^3.22.1: version "3.22.7" @@ -2700,14 +2671,14 @@ core-js@^2.4.0: integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-js@^3.4: - version "3.22.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.5.tgz#a5f5a58e663d5c0ebb4e680cd7be37536fb2a9cf" - integrity sha512-VP/xYuvJ0MJWRAobcmQ8F2H6Bsn+s7zqAAjFaHGBMc5AQm7zaelhD1LGduFn2EehEcQcU+br6t+fwbpQ5d1ZWA== + version "3.22.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.7.tgz#8d6c37f630f6139b8732d10f2c114c3f1d00024f" + integrity sha512-Jt8SReuDKVNZnZEzyEQT5eK6T2RRCXkfTq7Lo09kpm+fHjgGewSbNjV+Wt4yZMhPDdzz2x1ulI5z/w4nxpBseg== core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== core-util-is@~1.0.0: version "1.0.3" @@ -2787,14 +2758,14 @@ dash-ast@^1.0.0: dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== dependencies: assert-plus "^1.0.0" de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" - integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= + integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== debug@^2.6.9: version "2.6.9" @@ -2820,12 +2791,12 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: decamelize@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" - integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= + integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== deep-is@^0.1.3: version "0.1.4" @@ -2848,12 +2819,12 @@ define-properties@^1.1.3, define-properties@^1.1.4, define-properties@~1.1.2: defined@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" - integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM= + integrity sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ== delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" @@ -2884,13 +2855,13 @@ detect-newline@^3.0.0: integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== detective@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" - integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg== + version "5.2.1" + resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034" + integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw== dependencies: - acorn-node "^1.6.1" + acorn-node "^1.8.2" defined "^1.0.0" - minimist "^1.1.1" + minimist "^1.2.6" diff-match-patch@^1.0.5: version "1.0.5" @@ -2945,7 +2916,7 @@ doctrine@^3.0.0: doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" - integrity sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk= + integrity sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ== domain-browser@^1.2.0: version "1.2.0" @@ -2962,22 +2933,22 @@ domexception@^1.0.1: duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= + integrity sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA== dependencies: readable-stream "^2.0.2" ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== dependencies: jsbn "~0.1.0" safer-buffer "^2.1.0" electron-to-chromium@^1.4.118: - version "1.4.140" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.140.tgz#1b5836b7244aff341a11c8efd63dfe003dee4a19" - integrity sha512-NLz5va823QfJBYOO/hLV4AfU4Crmkl/6Hl2pH3qdJcmi0ySZ3YTWHxOlDm3uJOFBEPy3pIhu8gKQo6prQTWKKA== + version "1.4.142" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.142.tgz#70cc8871f7c0122b29256089989e67cee637b40d" + integrity sha512-ea8Q1YX0JRp4GylOmX4gFHIizi0j9GfRW4EkaHnkZp0agRCBB4ZGeCv17IEzIvBkiYVwfoKVhKZJbTfqCRdQdg== elliptic@^6.5.3: version "6.5.4" @@ -3085,7 +3056,7 @@ es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@ es6-iterator@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" - integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== dependencies: d "1" es5-ext "^0.10.35" @@ -3117,7 +3088,7 @@ escalade@^3.1.1: escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== escape-string-regexp@^2.0.0: version "2.0.0" @@ -3308,7 +3279,7 @@ esutils@^2.0.2: event-emitter@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== dependencies: d "1" es5-ext "~0.10.14" @@ -3344,12 +3315,12 @@ execa@^5.0.0: exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== exorcist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/exorcist/-/exorcist-1.0.1.tgz#79316e3c4885845490f7bb405c0e5b5db1167c52" - integrity sha1-eTFuPEiFhFSQ97tAXA5bXbEWfFI= + integrity sha512-YsUNvZ456n2BlgoAqQuroyla+4LyQAo7OUBVS2vUBW3CJWwQvEjtr3CKeka9RpkEFvKWecH41Mt6zZIjel54JQ== dependencies: is-stream "~1.1.0" minimist "0.0.5" @@ -3359,7 +3330,7 @@ exorcist@^1.0.1: expect@^1.20.2: version "1.20.2" resolved "https://registry.yarnpkg.com/expect/-/expect-1.20.2.tgz#d458fe4c56004036bae3232416a3f6361f04f965" - integrity sha1-1Fj+TFYAQDa64yMkFqP2Nh8E+WU= + integrity sha512-vUOB6rNLhhRgchrNzJZH72FXDgiHmmEqX07Nlb1363HyZm/GFzkNMq0X0eIygMtdc4f2okltziddtVM4D5q0Jw== dependencies: define-properties "~1.1.2" has "^1.0.1" @@ -3395,7 +3366,7 @@ extend@~3.0.2: extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== extsprintf@^1.2.0: version "1.4.1" @@ -3433,7 +3404,7 @@ fast-json-stable-stringify@^2.0.0: fast-levenshtein@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fast-safe-stringify@^2.0.7: version "2.1.1" @@ -3480,7 +3451,7 @@ find-cache-dir@^2.0.0: find-up@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= + integrity sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ== dependencies: locate-path "^2.0.0" @@ -3538,7 +3509,7 @@ foreground-child@^2.0.0: forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== form-data@^2.5.0: version "2.5.1" @@ -3566,7 +3537,7 @@ fs-readdir-recursive@^1.1.0: fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== fsevents@^2.3.2, fsevents@~2.3.2: version "2.3.2" @@ -3591,7 +3562,7 @@ function.prototype.name@^1.1.5: functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== functions-have-names@^1.2.2: version "1.2.3" @@ -3643,7 +3614,7 @@ get-symbol-description@^1.0.0: getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== dependencies: assert-plus "^1.0.0" @@ -3705,7 +3676,7 @@ graceful-fs@^4.2.9: har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== har-validator@~5.1.3: version "5.1.5" @@ -3723,7 +3694,7 @@ has-bigints@^1.0.1, has-bigints@^1.0.2: has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== has-flag@^4.0.0: version "4.0.0" @@ -3768,7 +3739,7 @@ hash-base@^3.0.0: hash-sum@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04" - integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ= + integrity sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA== hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" @@ -3786,7 +3757,7 @@ he@^1.1.0: hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== dependencies: hash.js "^1.0.3" minimalistic-assert "^1.0.0" @@ -3800,12 +3771,12 @@ html-escaper@^2.0.0: htmlescape@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" - integrity sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E= + integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg== http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== dependencies: assert-plus "^1.0.0" jsprim "^1.2.2" @@ -3814,7 +3785,7 @@ http-signature@~1.2.0: https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" - integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg== human-signals@^2.1.0: version "2.1.0" @@ -3850,12 +3821,12 @@ import-local@^3.0.2: imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== dependencies: once "^1.3.0" wrappy "1" @@ -3868,12 +3839,12 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + integrity sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA== inline-source-map@~0.6.0: version "0.6.2" resolved "https://registry.yarnpkg.com/inline-source-map/-/inline-source-map-0.6.2.tgz#f9393471c18a79d1724f863fa38b586370ade2a5" - integrity sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU= + integrity sha512-0mVWSSbNDvedDWIN4wxLsdPM4a7cIPcpyMxj3QZ406QRwQ6ePGB1YIHxVPjqpcUGbWQ5C+nHTwGNWAGvt7ggVA== dependencies: source-map "~0.5.3" @@ -3913,12 +3884,12 @@ is-arguments@^1.0.4, is-arguments@^1.1.0: is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== is-arrow-function@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-arrow-function/-/is-arrow-function-2.0.3.tgz#29be2c2d8d9450852b8bbafb635ba7b8d8e87ec2" - integrity sha1-Kb4sLY2UUIUri7r7Y1unuNjofsI= + integrity sha512-iDStzcT1FJMzx+TjCOK//uDugSe/Mif/8a+T0htydQ3qkJGvSweTZpVYz4hpJH0baloSPiAFQdA8WslAgJphvQ== dependencies: is-callable "^1.0.4" @@ -4005,7 +3976,7 @@ is-equal@^1.5.1: is-expression@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-3.0.0.tgz#39acaa6be7fd1f3471dc42c7416e61c24317ac9f" - integrity sha1-Oayqa+f9HzRx3ELHQW5hwkMXrJ8= + integrity sha512-vyMeQMq+AiH5uUnoBfMTwf18tO3bM6k1QXBE9D6ueAAquEfCZe3AJPtud9g6qS0+4X8xA7ndpZiDyeb2l2qOBw== dependencies: acorn "~4.0.2" object-assign "^4.0.1" @@ -4013,7 +3984,7 @@ is-expression@^3.0.0: is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== is-finalizationregistry@^1.0.2: version "1.0.2" @@ -4113,7 +4084,7 @@ is-stream@^2.0.0: is-stream@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" @@ -4143,12 +4114,12 @@ is-typed-array@^1.1.3, is-typed-array@^1.1.9: is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" - integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= + integrity sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q== is-weakmap@^2.0.1: version "2.0.1" @@ -4178,22 +4149,22 @@ isarray@^2.0.5: isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.0" @@ -4634,7 +4605,7 @@ jest@^28.0.0: js-stringify@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/js-stringify/-/js-stringify-1.0.2.tgz#1736fddfd9724f28a3682adc6230ae7e4e9679db" - integrity sha1-Fzb939lyTyijaCrcYjCufk6Weds= + integrity sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" @@ -4666,7 +4637,7 @@ js2xmlparser@^4.0.2: jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== jsdoc@^3.6.6: version "3.6.10" @@ -4697,7 +4668,7 @@ jsesc@^2.5.1: jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= + integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== json-parse-even-better-errors@^2.3.0: version "2.3.1" @@ -4717,12 +4688,12 @@ json-schema@0.4.0: json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== json5@^1.0.1: version "1.0.1" @@ -4739,7 +4710,7 @@ json5@^2.2.1: jsonparse@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== jsprim@^1.2.2: version "1.4.2" @@ -4754,7 +4725,7 @@ jsprim@^1.2.2: jstransformer@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" - integrity sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM= + integrity sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A== dependencies: is-promise "^2.0.0" promise "^7.0.1" @@ -4762,7 +4733,7 @@ jstransformer@1.0.0: kind-of@^3.0.2: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== dependencies: is-buffer "^1.1.5" @@ -4792,7 +4763,7 @@ labeled-stream-splicer@^2.0.0: lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= + integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ== leven@^3.1.0: version "3.1.0" @@ -4822,7 +4793,7 @@ linkify-it@^3.0.1: locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= + integrity sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA== dependencies: p-locate "^2.0.0" path-exists "^3.0.0" @@ -4852,33 +4823,28 @@ locate-path@^6.0.0: lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" - integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8= + integrity sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A== lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.sortby@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" - integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= - lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -4892,7 +4858,7 @@ loglevel@^1.7.1: longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= + integrity sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg== loose-envify@^1.4.0: version "1.4.0" @@ -4919,7 +4885,7 @@ lru-cache@^6.0.0: lru-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" - integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== dependencies: es5-ext "~0.10.2" @@ -4991,7 +4957,7 @@ md5.js@^1.3.4: mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" - integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= + integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== memoizee@^0.4.15: version "0.4.15" @@ -5063,7 +5029,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" - integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" @@ -5075,9 +5041,9 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: minimist@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.5.tgz#d7aa327bcecf518f9106ac6b8f003fa3bcea8566" - integrity sha1-16oye87PUY+RBqxrjwA/o7zqhWY= + integrity sha512-rSJ0cdmCj3qmKdObcnMcWgPVOyaOWlazLhZAJW0s6G6lx1ZEuFkraWmEH5LTvX90btkfHPclQBjvjU7A/kYRFg== -minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.6: +minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== @@ -5123,7 +5089,7 @@ module-deps@^6.2.3: mold-source-map@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/mold-source-map/-/mold-source-map-0.4.0.tgz#cf67e0b31c47ab9badb5c9c25651862127bb8317" - integrity sha1-z2fgsxxHq5uttcnCVlGGISe7gxc= + integrity sha512-Y0uA/sDKVuPgLd7BmaJOai+fqzjrOlR6vZgx5cJIvturI/xOPQPgbf3X7ZbzJd6MvqQ6ucIfK8dSteFyc2Mw2w== dependencies: convert-source-map "^1.1.0" through "~2.2.7" @@ -5131,7 +5097,7 @@ mold-source-map@~0.4.0: ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== ms@2.1.2: version "2.1.2" @@ -5146,7 +5112,7 @@ ms@^2.1.1: natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== neo-async@^2.6.1: version "2.6.2" @@ -5205,9 +5171,9 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= object-inspect@^1.1.0, object-inspect@^1.12.0, object-inspect@^1.9.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" - integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== + version "1.12.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" + integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== object-keys@^1.0.9, object-keys@^1.1.1: version "1.1.1" @@ -5746,9 +5712,9 @@ react-ace@^9.5.0: prop-types "^15.7.2" react-docgen@^5.4.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/react-docgen/-/react-docgen-5.4.0.tgz#2cd7236720ec2769252ef0421f23250b39a153a1" - integrity sha512-JBjVQ9cahmNlfjMGxWUxJg919xBBKAoy3hgDgKERbR+BcF4ANpDuzWAScC7j27hZfd8sJNmMPOLWo9+vB/XJEQ== + version "5.4.1" + resolved "https://registry.yarnpkg.com/react-docgen/-/react-docgen-5.4.1.tgz#867168accce39e25095a23a922eaa90722e9d182" + integrity sha512-TZqD1aApirw86NV6tHrmDoxUn8wlinkVyutFarzbdwuhEurAzDN0y5sSj64o+BrHLPqjwpH9tunpfwgy+3Uyww== dependencies: "@babel/core" "^7.7.5" "@babel/generator" "^7.12.11" @@ -5762,9 +5728,9 @@ react-docgen@^5.4.0: strip-indent "^3.0.0" react-frame-component@^5.2.1: - version "5.2.2" - resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-5.2.2.tgz#588711562d07f37741798aa4844b88e637212d2b" - integrity sha512-nqQaNUHUlLf3VFMWhFmURWaK2TS8RxZxptnU4JY4D2p059vIIJbdqyfJvwYoIHZoQ2h8Gm4mPfzfvcF57zOY7g== + version "5.2.3" + resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-5.2.3.tgz#2d5d1e29b23d5b915c839b44980d03bb9cafc453" + integrity sha512-r+h0o3r/uqOLNT724z4CRVkxQouKJvoi3OPfjqWACD30Y87rtEmeJrNZf1WYPGknn1Y8200HAjx7hY/dPUGgmA== react-is@^16.13.1: version "16.13.1" @@ -6164,13 +6130,6 @@ source-map@~0.5.1, source-map@~0.5.3: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -source-map@~0.8.0-beta.0: - version "0.8.0-beta.0" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" - integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== - dependencies: - whatwg-url "^7.0.0" - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -6389,13 +6348,13 @@ terminal-link@^2.0.0: supports-hyperlinks "^2.0.0" terser@^5.5.1: - version "5.13.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.13.1.tgz#66332cdc5a01b04a224c9fad449fc1a18eaa1799" - integrity sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA== + version "5.14.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.0.tgz#eefeec9af5153f55798180ee2617f390bdd285e2" + integrity sha512-JC6qfIEkPBd9j1SMO3Pfn+A6w2kQV54tv+ABQLgZr7dA3k/DL/OBoYSWxzVpZev3J+bUHXfr55L8Mox7AaNo6g== dependencies: + "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" commander "^2.20.0" - source-map "~0.8.0-beta.0" source-map-support "~0.5.20" test-exclude@^6.0.0: @@ -6490,13 +6449,6 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" -tr46@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" - integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= - dependencies: - punycode "^2.1.0" - tr46@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" @@ -6868,15 +6820,6 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" -whatwg-url@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" - integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== - dependencies: - lodash.sortby "^4.7.0" - tr46 "^1.0.1" - webidl-conversions "^4.0.2" - whatwg-url@^8.4.0: version "8.7.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" From 8412ccfa9bac510056fae8212ea8020ad6dd48c2 Mon Sep 17 00:00:00 2001 From: Faye Duxovni Date: Wed, 1 Jun 2022 00:43:23 -0400 Subject: [PATCH 03/41] Try to load keys from key backup when a message fails to decrypt (#2373) Co-authored-by: Travis Ralston --- spec/TestClient.js | 238 --------------------- spec/TestClient.ts | 239 ++++++++++++++++++++++ spec/integ/matrix-client-crypto.spec.js | 2 +- spec/integ/matrix-client-retrying.spec.ts | 20 +- spec/integ/megolm-backup.spec.ts | 165 +++++++++++++++ spec/unit/webrtc/callEventHandler.spec.ts | 19 +- src/client.ts | 12 ++ src/crypto/algorithms/megolm.ts | 4 +- src/crypto/backup.ts | 23 +++ src/crypto/keybackup.ts | 14 +- 10 files changed, 484 insertions(+), 252 deletions(-) delete mode 100644 spec/TestClient.js create mode 100644 spec/TestClient.ts create mode 100644 spec/integ/megolm-backup.spec.ts diff --git a/spec/TestClient.js b/spec/TestClient.js deleted file mode 100644 index 7b2474c15ca..00000000000 --- a/spec/TestClient.js +++ /dev/null @@ -1,238 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018-2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// load olm before the sdk if possible -import './olm-loader'; - -import MockHttpBackend from 'matrix-mock-request'; - -import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; -import { logger } from '../src/logger'; -import { WebStorageSessionStore } from "../src/store/session/webstorage"; -import { syncPromise } from "./test-utils/test-utils"; -import { createClient } from "../src/matrix"; -import { MockStorageApi } from "./MockStorageApi"; - -/** - * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient - * - * @constructor - * @param {string} userId - * @param {string} deviceId - * @param {string} accessToken - * - * @param {WebStorage=} sessionStoreBackend a web storage object to use for the - * session store. If undefined, we will create a MockStorageApi. - * @param {object} options additional options to pass to the client - */ -export function TestClient( - userId, deviceId, accessToken, sessionStoreBackend, options, -) { - this.userId = userId; - this.deviceId = deviceId; - - if (sessionStoreBackend === undefined) { - sessionStoreBackend = new MockStorageApi(); - } - const sessionStore = new WebStorageSessionStore(sessionStoreBackend); - - this.httpBackend = new MockHttpBackend(); - - options = Object.assign({ - baseUrl: "http://" + userId + ".test.server", - userId: userId, - accessToken: accessToken, - deviceId: deviceId, - sessionStore: sessionStore, - request: this.httpBackend.requestFn, - }, options); - if (!options.cryptoStore) { - // expose this so the tests can get to it - this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); - options.cryptoStore = this.cryptoStore; - } - this.client = createClient(options); - - this.deviceKeys = null; - this.oneTimeKeys = {}; - this.callEventHandler = { - calls: new Map(), - }; -} - -TestClient.prototype.toString = function() { - return 'TestClient[' + this.userId + ']'; -}; - -/** - * start the client, and wait for it to initialise. - * - * @return {Promise} - */ -TestClient.prototype.start = function() { - logger.log(this + ': starting'); - this.httpBackend.when("GET", "/versions").respond(200, {}); - this.httpBackend.when("GET", "/pushrules").respond(200, {}); - this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - this.expectDeviceKeyUpload(); - - // we let the client do a very basic initial sync, which it needs before - // it will upload one-time keys. - this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); - - this.client.startClient({ - // set this so that we can get hold of failed events - pendingEventOrdering: 'detached', - }); - - return Promise.all([ - this.httpBackend.flushAllExpected(), - syncPromise(this.client), - ]).then(() => { - logger.log(this + ': started'); - }); -}; - -/** - * stop the client - * @return {Promise} Resolves once the mock http backend has finished all pending flushes - */ -TestClient.prototype.stop = function() { - this.client.stopClient(); - return this.httpBackend.stop(); -}; - -/** - * Set up expectations that the client will upload device keys. - */ -TestClient.prototype.expectDeviceKeyUpload = function() { - const self = this; - this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) { - expect(content.one_time_keys).toBe(undefined); - expect(content.device_keys).toBeTruthy(); - - logger.log(self + ': received device keys'); - // we expect this to happen before any one-time keys are uploaded. - expect(Object.keys(self.oneTimeKeys).length).toEqual(0); - - self.deviceKeys = content.device_keys; - return { one_time_key_counts: { signed_curve25519: 0 } }; - }); -}; - -/** - * If one-time keys have already been uploaded, return them. Otherwise, - * set up an expectation that the keys will be uploaded, and wait for - * that to happen. - * - * @returns {Promise} for the one-time keys - */ -TestClient.prototype.awaitOneTimeKeyUpload = function() { - if (Object.keys(this.oneTimeKeys).length != 0) { - // already got one-time keys - return Promise.resolve(this.oneTimeKeys); - } - - this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { - expect(content.device_keys).toBe(undefined); - expect(content.one_time_keys).toBe(undefined); - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys).length, - } }; - }); - - this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { - expect(content.device_keys).toBe(undefined); - expect(content.one_time_keys).toBeTruthy(); - expect(content.one_time_keys).not.toEqual({}); - logger.log('%s: received %i one-time keys', this, - Object.keys(content.one_time_keys).length); - this.oneTimeKeys = content.one_time_keys; - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys).length, - } }; - }); - - // this can take ages - return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { - expect(flushed).toEqual(2); - return this.oneTimeKeys; - }); -}; - -/** - * Set up expectations that the client will query device keys. - * - * We check that the query contains each of the users in `response`. - * - * @param {Object} response response to the query. - */ -TestClient.prototype.expectKeyQuery = function(response) { - this.httpBackend.when('POST', '/keys/query').respond( - 200, (path, content) => { - Object.keys(response.device_keys).forEach((userId) => { - expect(content.device_keys[userId]).toEqual( - [], - "Expected key query for " + userId + ", got " + - Object.keys(content.device_keys), - ); - }); - return response; - }); -}; - -/** - * get the uploaded curve25519 device key - * - * @return {string} base64 device key - */ -TestClient.prototype.getDeviceKey = function() { - const keyId = 'curve25519:' + this.deviceId; - return this.deviceKeys.keys[keyId]; -}; - -/** - * get the uploaded ed25519 device key - * - * @return {string} base64 device key - */ -TestClient.prototype.getSigningKey = function() { - const keyId = 'ed25519:' + this.deviceId; - return this.deviceKeys.keys[keyId]; -}; - -/** - * flush a single /sync request, and wait for the syncing event - * - * @returns {Promise} promise which completes once the sync has been flushed - */ -TestClient.prototype.flushSync = function() { - logger.log(`${this}: flushSync`); - return Promise.all([ - this.httpBackend.flush('/sync', 1), - syncPromise(this.client), - ]).then(() => { - logger.log(`${this}: flushSync completed`); - }); -}; - -TestClient.prototype.isFallbackICEServerAllowed = function() { - return true; -}; diff --git a/spec/TestClient.ts b/spec/TestClient.ts new file mode 100644 index 00000000000..42eb5fa28da --- /dev/null +++ b/spec/TestClient.ts @@ -0,0 +1,239 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// load olm before the sdk if possible +import './olm-loader'; + +import MockHttpBackend from 'matrix-mock-request'; + +import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; +import { logger } from '../src/logger'; +import { WebStorageSessionStore } from "../src/store/session/webstorage"; +import { syncPromise } from "./test-utils/test-utils"; +import { createClient } from "../src/matrix"; +import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client"; +import { MockStorageApi } from "./MockStorageApi"; +import { encodeUri } from "../src/utils"; +import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; +import { IKeyBackupSession } from "../src/crypto/keybackup"; + +/** + * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient + */ +export class TestClient { + public readonly httpBackend: MockHttpBackend; + public readonly client: MatrixClient; + private deviceKeys: IDeviceKeys; + private oneTimeKeys: Record; + + constructor( + public readonly userId?: string, + public readonly deviceId?: string, + accessToken?: string, + sessionStoreBackend?: Storage, + options?: Partial, + ) { + if (sessionStoreBackend === undefined) { + sessionStoreBackend = new MockStorageApi(); + } + const sessionStore = new WebStorageSessionStore(sessionStoreBackend); + + this.httpBackend = new MockHttpBackend(); + + const fullOptions: ICreateClientOpts = { + baseUrl: "http://" + userId + ".test.server", + userId: userId, + accessToken: accessToken, + deviceId: deviceId, + sessionStore: sessionStore, + request: this.httpBackend.requestFn, + ...options, + }; + if (!fullOptions.cryptoStore) { + // expose this so the tests can get to it + fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); + } + this.client = createClient(fullOptions); + + this.deviceKeys = null; + this.oneTimeKeys = {}; + } + + public toString(): string { + return 'TestClient[' + this.userId + ']'; + } + + /** + * start the client, and wait for it to initialise. + */ + public start(): Promise { + logger.log(this + ': starting'); + this.httpBackend.when("GET", "/versions").respond(200, {}); + this.httpBackend.when("GET", "/pushrules").respond(200, {}); + this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + this.expectDeviceKeyUpload(); + + // we let the client do a very basic initial sync, which it needs before + // it will upload one-time keys. + this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); + + this.client.startClient({ + // set this so that we can get hold of failed events + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + return Promise.all([ + this.httpBackend.flushAllExpected(), + syncPromise(this.client), + ]).then(() => { + logger.log(this + ': started'); + }); + } + + /** + * stop the client + * @return {Promise} Resolves once the mock http backend has finished all pending flushes + */ + public stop(): Promise { + this.client.stopClient(); + return this.httpBackend.stop(); + } + + /** + * Set up expectations that the client will upload device keys. + */ + public expectDeviceKeyUpload() { + this.httpBackend.when("POST", "/keys/upload").respond(200, (path, content) => { + expect(content.one_time_keys).toBe(undefined); + expect(content.device_keys).toBeTruthy(); + + logger.log(this + ': received device keys'); + // we expect this to happen before any one-time keys are uploaded. + expect(Object.keys(this.oneTimeKeys).length).toEqual(0); + + this.deviceKeys = content.device_keys; + return { one_time_key_counts: { signed_curve25519: 0 } }; + }); + } + + /** + * If one-time keys have already been uploaded, return them. Otherwise, + * set up an expectation that the keys will be uploaded, and wait for + * that to happen. + * + * @returns {Promise} for the one-time keys + */ + public awaitOneTimeKeyUpload(): Promise> { + if (Object.keys(this.oneTimeKeys).length != 0) { + // already got one-time keys + return Promise.resolve(this.oneTimeKeys); + } + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBe(undefined); + return { one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + } }; + }); + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBeTruthy(); + expect(content.one_time_keys).not.toEqual({}); + logger.log('%s: received %i one-time keys', this, + Object.keys(content.one_time_keys).length); + this.oneTimeKeys = content.one_time_keys; + return { one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + } }; + }); + + // this can take ages + return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { + expect(flushed).toEqual(2); + return this.oneTimeKeys; + }); + } + + /** + * Set up expectations that the client will query device keys. + * + * We check that the query contains each of the users in `response`. + * + * @param {Object} response response to the query. + */ + public expectKeyQuery(response: IDownloadKeyResult) { + this.httpBackend.when('POST', '/keys/query').respond( + 200, (path, content) => { + Object.keys(response.device_keys).forEach((userId) => { + expect(content.device_keys[userId]).toEqual([]); + }); + return response; + }); + } + + /** + * Set up expectations that the client will query key backups for a particular session + */ + public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) { + this.httpBackend.when('GET', encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId, + $sessionId: sessionId, + })).respond(status, response); + } + + /** + * get the uploaded curve25519 device key + * + * @return {string} base64 device key + */ + public getDeviceKey(): string { + const keyId = 'curve25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; + } + + /** + * get the uploaded ed25519 device key + * + * @return {string} base64 device key + */ + public getSigningKey(): string { + const keyId = 'ed25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; + } + + /** + * flush a single /sync request, and wait for the syncing event + */ + public flushSync(): Promise { + logger.log(`${this}: flushSync`); + return Promise.all([ + this.httpBackend.flush('/sync', 1), + syncPromise(this.client), + ]).then(() => { + logger.log(`${this}: flushSync completed`); + }); + } + + public isFallbackICEServerAllowed(): boolean { + return true; + } +} diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 954b62a76f6..a886ccab7a1 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -161,7 +161,7 @@ function aliDownloadsKeys() { return Promise.all([p1, p2]).then(() => { return aliTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { - aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const devices = data.devices[bobUserId]; expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); expect(devices[bobDeviceId].verified). diff --git a/spec/integ/matrix-client-retrying.spec.ts b/spec/integ/matrix-client-retrying.spec.ts index 6f74e4188b8..31354b89a65 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -1,10 +1,26 @@ -import { EventStatus, RoomEvent } from "../../src/matrix"; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; describe("MatrixClient retrying", function() { - let client: TestClient = null; + let client: MatrixClient = null; let httpBackend: TestClient["httpBackend"] = null; let scheduler; const userId = "@alice:localhost"; diff --git a/spec/integ/megolm-backup.spec.ts b/spec/integ/megolm-backup.spec.ts new file mode 100644 index 00000000000..5fa6755192f --- /dev/null +++ b/spec/integ/megolm-backup.spec.ts @@ -0,0 +1,165 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Account } from "@matrix-org/olm"; + +import { logger } from "../../src/logger"; +import { decodeRecoveryKey } from "../../src/crypto/recoverykey"; +import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup"; +import { TestClient } from "../TestClient"; +import { IEvent } from "../../src"; +import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; + +const ROOM_ID = '!ROOM:ID'; + +const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; + +const ENCRYPTED_EVENT: Partial = { + type: 'm.room.encrypted', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: 'SENDER_CURVE25519', + session_id: SESSION_ID, + ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' + + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' + + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', + }, + room_id: '!ROOM:ID', + event_id: '$event1', + origin_server_ts: 1507753886000, +}; + +const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = { + first_message_index: 0, + forwarded_count: 0, + is_verified: false, + session_data: { + ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw' + + '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ' + + 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9' + + 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy' + + 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF' + + 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV' + + '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv' + + 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe' + + 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf' + + 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy' + + 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg', + mac: '5lxYBHQU80M', + ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14', + }, +}; + +const CURVE25519_BACKUP_INFO: IKeyBackupInfo = { + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + version: "1", + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, +}; + +const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d"; + +/** + * start an Olm session with a given recipient + */ +function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise { + return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => { + const otkId = Object.keys(keys)[0]; + const otk = keys[otkId]; + + const session = new global.Olm.Session(); + session.create_outbound( + olmAccount, recipientTestClient.getDeviceKey(), otk.key, + ); + return session; + }); +} + +describe("megolm key backups", function() { + if (!global.Olm) { + logger.warn('not running megolm tests: Olm not present'); + return; + } + const Olm = global.Olm; + + let testOlmAccount: Account; + let aliceTestClient: TestClient; + + beforeAll(function() { + return Olm.init(); + }); + + beforeEach(async function() { + aliceTestClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", + ); + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + await aliceTestClient.client.initCrypto(); + aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; + }); + + afterEach(function() { + return aliceTestClient.stop(); + }); + + it("Alice checks key backups when receiving a message she can't decrypt", function() { + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [ENCRYPTED_EVENT], + }, + }; + + return aliceTestClient.start().then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }).then(() => { + const privkey = decodeRecoveryKey(RECOVERY_KEY); + return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey); + }).then(() => { + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + aliceTestClient.expectKeyBackupQuery( + ROOM_ID, + SESSION_ID, + 200, + CURVE25519_KEY_BACKUP_DATA, + ); + return aliceTestClient.httpBackend.flushAllExpected(); + }).then(function(): Promise { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + + if (event.getContent()) { + return Promise.resolve(event); + } + + return new Promise((resolve, reject) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); + }).then((event) => { + expect(event.getContent()).toEqual('testytest'); + }); + }); +}); diff --git a/spec/unit/webrtc/callEventHandler.spec.ts b/spec/unit/webrtc/callEventHandler.spec.ts index d60ae299791..a8103f0d5f2 100644 --- a/spec/unit/webrtc/callEventHandler.spec.ts +++ b/spec/unit/webrtc/callEventHandler.spec.ts @@ -15,7 +15,16 @@ limitations under the License. */ import { TestClient } from '../../TestClient'; -import { ClientEvent, EventType, MatrixEvent, RoomEvent } from "../../../src"; +import { + ClientEvent, + EventTimeline, + EventTimelineSet, + EventType, + IRoomTimelineData, + MatrixEvent, + Room, + RoomEvent, +} from "../../../src"; import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler"; import { SyncState } from "../../../src/sync"; @@ -23,6 +32,8 @@ describe("callEventHandler", () => { it("should ignore a call if invite & hangup come within a single sync", () => { const testClient = new TestClient(); const client = testClient.client; + const room = new Room("!room:id", client, "@user:id"); + const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) }; client.callEventHandler = new CallEventHandler(client); client.callEventHandler.start(); @@ -33,7 +44,7 @@ describe("callEventHandler", () => { call_id: "123", }, }); - client.emit(RoomEvent.Timeline, callInvite); + client.emit(RoomEvent.Timeline, callInvite, room, false, false, timelineData); const callHangup = new MatrixEvent({ type: EventType.CallHangup, @@ -41,13 +52,13 @@ describe("callEventHandler", () => { call_id: "123", }, }); - client.emit(RoomEvent.Timeline, callHangup); + client.emit(RoomEvent.Timeline, callHangup, room, false, false, timelineData); const incomingCallEmitted = jest.fn(); client.on(CallEventHandlerEvent.Incoming, incomingCallEmitted); client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing); - client.emit(ClientEvent.Sync); + client.emit(ClientEvent.Sync, SyncState.Syncing); expect(incomingCallEmitted).not.toHaveBeenCalled(); }); diff --git a/src/client.ts b/src/client.ts index 1e97cc80325..04555b7c16b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3100,6 +3100,18 @@ export class MatrixClient extends TypedEventEmitter { + const privKey = await this.crypto.backupManager.getKey(); + return this.restoreKeyBackup( + privKey, targetRoomId, targetSessionId, backupInfo, opts, + ); + } + private async restoreKeyBackup( privKey: ArrayLike, targetRoomId: undefined, diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index f960dd4f15e..b7bb1165a6e 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -1298,7 +1298,9 @@ class MegolmDecryption extends DecryptionAlgorithm { if (res === null) { // We've got a message for a session we don't have. - // + // try and get the missing key from the backup first + this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); + // (XXX: We might actually have received this key since we started // decrypting, in which case we'll have scheduled a retry, and this // request will be redundant. We could probably check to see if the diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 9b17c84c5e4..94ec3c62445 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -35,6 +35,7 @@ import { UnstableValue } from "../NamespacedValue"; import { CryptoEvent, IMegolmSessionData } from "./index"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; +const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms type AuthData = IKeyBackupInfo["auth_data"]; @@ -111,6 +112,8 @@ export class BackupManager { public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public checkedForBackup: boolean; // Have we checked the server for a backup we can use? private sendingBackups: boolean; // Are we currently sending backups? + private sessionLastCheckAttemptedTime: Record = {}; // When did we last try to check the server for a given session id? + constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { this.checkedForBackup = false; this.sendingBackups = false; @@ -282,6 +285,26 @@ export class BackupManager { return this.checkAndStart(); } + /** + * Attempts to retrieve a session from a key backup, if enough time + * has elapsed since the last check for this session id. + */ + public async queryKeyBackupRateLimited( + targetRoomId: string | undefined, + targetSessionId: string | undefined, + ): Promise { + if (!this.backupInfo) { return; } + + const now = new Date().getTime(); + if ( + !this.sessionLastCheckAttemptedTime[targetSessionId] + || now - this.sessionLastCheckAttemptedTime[targetSessionId] > KEY_BACKUP_CHECK_RATE_LIMIT + ) { + this.sessionLastCheckAttemptedTime[targetSessionId] = now; + await this.baseApis.restoreKeyBackupWithBackupManager(targetRoomId, targetSessionId, this.backupInfo, {}); + } + } + /** * Check if the given backup info is trusted. * diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 1b7ae52405d..16135163280 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -15,17 +15,19 @@ limitations under the License. */ import { ISigned } from "../@types/signed"; +import { IEncryptedPayload } from "./aes"; + +export interface Curve25519SessionData { + ciphertext: string; + ephemeral: string; + mac: string; +} export interface IKeyBackupSession { first_message_index: number; // eslint-disable-line camelcase forwarded_count: number; // eslint-disable-line camelcase is_verified: boolean; // eslint-disable-line camelcase - session_data: { // eslint-disable-line camelcase - ciphertext: string; - ephemeral: string; - mac: string; - iv: string; - }; + session_data: Curve25519SessionData | IEncryptedPayload; // eslint-disable-line camelcase } export interface IKeyBackupRoomSessions { From 2e27a4134c71f28d782483037adce07670511dba Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Jun 2022 09:37:44 +0100 Subject: [PATCH 04/41] Fix test suite regression due to TestClient refactoring (#2426) --- spec/integ/devicelist-integ.spec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/integ/devicelist-integ.spec.js b/spec/integ/devicelist-integ.spec.js index 99c9b2f060b..8be2ca59ace 100644 --- a/spec/integ/devicelist-integ.spec.js +++ b/spec/integ/devicelist-integ.spec.js @@ -167,7 +167,7 @@ describe("DeviceList management:", function() { aliceTestClient.client.crypto.deviceList.saveIfDirty(), ]); }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { expect(data.syncToken).toEqual(1); }); @@ -203,7 +203,7 @@ describe("DeviceList management:", function() { expect(flushed).toEqual(0); return aliceTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; if (bobStat != 1 && bobStat != 2) { throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + @@ -236,7 +236,7 @@ describe("DeviceList management:", function() { }).then(() => { return aliceTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual(3); const chrisStat = data.trackingStatus['@chris:abc']; @@ -257,7 +257,7 @@ describe("DeviceList management:", function() { }).then(() => { return aliceTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; const chrisStat = data.trackingStatus['@bob:xyz']; @@ -287,7 +287,7 @@ describe("DeviceList management:", function() { await aliceTestClient.httpBackend.flush('/keys/query', 1); await aliceTestClient.client.crypto.deviceList.saveIfDirty(); - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toBeGreaterThan( @@ -323,7 +323,7 @@ describe("DeviceList management:", function() { await aliceTestClient.flushSync(); await aliceTestClient.client.crypto.deviceList.saveIfDirty(); - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual( @@ -359,7 +359,7 @@ describe("DeviceList management:", function() { await aliceTestClient.flushSync(); await aliceTestClient.client.crypto.deviceList.saveIfDirty(); - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual( @@ -380,7 +380,7 @@ describe("DeviceList management:", function() { await anotherTestClient.flushSync(); await anotherTestClient.client.crypto.deviceList.saveIfDirty(); - anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual( From b64dbdce74b0d162d3a036dc248fb3524823e386 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 1 Jun 2022 16:31:20 -0500 Subject: [PATCH 05/41] Timeline needs to refresh when we see a MSC2716 marker event (#2299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inform the client that historical messages were imported in the timeline and they should refresh the timeline in order to see the new events. Companion `matrix-react-sdk` PR: https://github.com/matrix-org/matrix-react-sdk/pull/8354 The `marker` events are being used as state now because this way they can't be lost in a timeline gap. Regardless of when they were sent, we will still have the latest version of the state to compare against. Any time we see our latest state value change for marker events, prompt the user that the timeline needs to refresh. > In a [sync meeting with @ara4n](https://docs.google.com/document/d/1KCEmpnGr4J-I8EeaVQ8QJZKBDu53ViI7V62y5BzfXr0/edit#bookmark=id.67nio1ka8znc), we came up with the idea to make the `marker` events as state events. When the client sees that the `m.room.marker` state changed to a different event ID, it can throw away all of the timeline and re-fetch as needed. > > For homeservers where the [same problem](https://github.com/matrix-org/matrix-doc/pull/2716#discussion_r782499674) can happen, we probably don't want to throw away the whole timeline but it can go up the `unsigned.replaces_state` chain of the `m.room.marker` state events to get them all. > > In terms of state performance, there could be thousands of `marker` events in a room but it's no different than room members joining and leaving over and over like an IRC room. > > *-- https://github.com/matrix-org/matrix-spec-proposals/pull/2716#discussion_r782629097* ### Why are we just setting `timlineNeedsRefresh` (and [prompting the user](https://github.com/matrix-org/matrix-react-sdk/pull/8354)) instead of automatically refreshing the timeline for the user? If we refreshed the timeline automatically, someone could cause your Element client to constantly refresh the timeline by just sending marker events over and over. Granted, you probably want to leave a room like this 🤷. Perhaps also some sort of DOS vector since everyone will be refreshing and hitting the server at the exact same time. In order to avoid the timeline maybe going blank during the refresh, we could re-fetch the new events first, then replace the timeline. But the points above still stand on why we shouldn't. --- .../matrix-client-event-timeline.spec.js | 71 +++ .../integ/matrix-client-room-timeline.spec.js | 270 +++++++++- spec/integ/matrix-client-syncing.spec.js | 482 +++++++++++++++++- spec/unit/event-timeline-set.spec.ts | 71 +++ spec/unit/event-timeline.spec.js | 57 ++- spec/unit/room-state.spec.js | 25 +- spec/unit/room.spec.ts | 88 +++- spec/unit/timeline-window.spec.js | 5 +- src/@types/event.ts | 8 + src/client.ts | 44 +- src/models/event-timeline-set.ts | 137 ++++- src/models/event-timeline.ts | 86 +++- src/models/room-state.ts | 35 +- src/models/room.ts | 250 +++++++-- src/models/thread.ts | 8 +- src/sync.ts | 123 ++++- 16 files changed, 1635 insertions(+), 125 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 6df4a9a813c..454a4d30323 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -540,6 +540,77 @@ describe("MatrixClient event timelines", function() { }); }); + describe("getLatestTimeline", function() { + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + const latestMessageId = 'event1:bar'; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, function() { + return { + chunk: [{ + event_id: latestMessageId, + }], + }; + }); + + httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + ROOM_NAME_EVENT, + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + return Promise.all([ + client.getLatestTimeline(timelineSet).then(function(tl) { + // Instead of this assertion logic, we could just add a spy + // for `getEventTimeline` and make sure it's called with the + // correct parameters. This doesn't feel too bad to make sure + // `getLatestTimeline` is doing the right thing though. + expect(tl.getEvents().length).toEqual(4); + for (let i = 0; i < 4; i++) { + expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl.getEvents()[i].sender.name).toEqual(userName); + } + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + it("should throw error when /messages does not return a message", () => { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, () => { + return { + chunk: [ + // No messages to return + ], + }; + }); + + return Promise.all([ + expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(), + httpBackend.flushAllExpected(), + ]); + }); + }); + describe("paginateEventTimeline", function() { it("should allow you to paginate backwards", function() { const room = client.getRoom(roomId); diff --git a/spec/integ/matrix-client-room-timeline.spec.js b/spec/integ/matrix-client-room-timeline.spec.js index edb38175b36..acf751a8c09 100644 --- a/spec/integ/matrix-client-room-timeline.spec.js +++ b/spec/integ/matrix-client-room-timeline.spec.js @@ -1,5 +1,6 @@ import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; +import { RoomEvent } from "../../src"; import { TestClient } from "../TestClient"; describe("MatrixClient room timelines", function() { @@ -579,7 +580,7 @@ describe("MatrixClient room timelines", function() { }); }); - it("should emit a 'Room.timelineReset' event", function() { + it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function() { const eventData = [ utils.mkMessage({ user: userId, room: roomId }), ]; @@ -608,4 +609,271 @@ describe("MatrixClient room timelines", function() { }); }); }); + + describe('Refresh live timeline', () => { + const initialSyncEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + utils.mkMessage({ user: userId, room: roomId }), + utils.mkMessage({ user: userId, room: roomId }), + ]; + + const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` + + `${encodeURIComponent(initialSyncEventData[2].event_id)}`; + const contextResponse = { + start: "start_token", + events_before: [initialSyncEventData[1], initialSyncEventData[0]], + event: initialSyncEventData[2], + events_after: [], + state: [ + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + + let room; + beforeEach(async () => { + setNextSyncData(initialSyncEventData); + + // Create a room from the sync + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Get the room after the first sync so the room is created + room = client.getRoom(roomId); + expect(room).toBeTruthy(); + }); + + it('should clear and refresh messages in timeline', async () => { + // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` + // to construct a new timeline from. + httpBackend.when("GET", contextUrl) + .respond(200, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return contextResponse; + }); + + // Refresh the timeline. + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Make sure the message are visible + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + initialSyncEventData[0].event_id, + initialSyncEventData[1].event_id, + initialSyncEventData[2].event_id, + ]); + }); + + it('Perfectly merges timelines if a sync finishes while refreshing the timeline', async () => { + // `/context` request for `refreshLiveTimeline()` -> + // `getEventTimeline()` to construct a new timeline from. + // + // We only resolve this request after we detect that the timeline + // was reset(when it goes blank) and force a sync to happen in the + // middle of all of this refresh timeline logic. We want to make + // sure the sync pagination still works as expected after messing + // the refresh timline logic messes with the pagination tokens. + httpBackend.when("GET", contextUrl) + .respond(200, () => { + // Now finally return and make the `/context` request respond + return contextResponse; + }); + + // Wait for the timeline to reset(when it goes blank) which means + // it's in the middle of the refrsh logic right before the + // `getEventTimeline()` -> `/context`. Then simulate a racey `/sync` + // to happen in the middle of all of this refresh timeline logic. We + // want to make sure the sync pagination still works as expected + // after messing the refresh timline logic messes with the + // pagination tokens. + // + // We define this here so the event listener is in place before we + // call `room.refreshLiveTimeline()`. + const racingSyncEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { + let eventFired = false; + // Throw a more descriptive error if this part of the test times out. + const failTimeout = setTimeout(() => { + if (eventFired) { + reject(new Error( + 'TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make' + + 'a `/sync` happen in time.', + )); + } else { + reject(new Error( + 'TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.', + )); + } + }, 4000 /* FIXME: Is there a way to reference the current timeout of this test in Jest? */); + + room.on(RoomEvent.TimelineReset, async () => { + try { + eventFired = true; + + // The timeline should be cleared at this point in the refresh + expect(room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length).toEqual(0); + + // Then make a `/sync` happen by sending a message and seeing that it + // shows up (simulate a /sync naturally racing with us). + setNextSyncData(racingSyncEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client, 1), + ]); + // Make sure the timeline has the racey sync data + const afterRaceySyncTimelineEvents = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents(); + const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents + .map((event) => event.getId()); + expect(afterRaceySyncTimelineEventIds).toEqual([ + racingSyncEventData[0].event_id, + ]); + + clearTimeout(failTimeout); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + + // Refresh the timeline. Just start the function, we will wait for + // it to finish after the racey sync. + const refreshLiveTimelinePromise = room.refreshLiveTimeline(); + + await waitForRaceySyncAfterResetPromise; + + await Promise.all([ + refreshLiveTimelinePromise, + // Then flush the remaining `/context` to left the refresh logic complete + httpBackend.flushAllExpected(), + ]); + + // Make sure sync pagination still works by seeing a new message show up + // after refreshing the timeline. + const afterRefreshEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + setNextSyncData(afterRefreshEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Make sure the timeline includes the the events from the `/sync` + // that raced and beat us in the middle of everything and the + // `/sync` after the refresh. Since the `/sync` beat us to create + // the timeline, `initialSyncEventData` won't be visible unless we + // paginate backwards with `/messages`. + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + racingSyncEventData[0].event_id, + afterRefreshEventData[0].event_id, + ]); + }); + + it('Timeline recovers after `/context` request to generate new timeline fails', async () => { + // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` + // to construct a new timeline from. + httpBackend.when("GET", contextUrl) + .respond(500, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return { + errcode: 'TEST_FAKE_ERROR', + error: 'We purposely intercepted this /context request to make it fail ' + + 'in order to test whether the refresh timeline code is resilient', + }; + }); + + // Refresh the timeline and expect it to fail + const settledFailedRefreshPromises = await Promise.allSettled([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + // We only expect `TEST_FAKE_ERROR` here. Anything else is + // unexpected and should fail the test. + if (settledFailedRefreshPromises[0].status === 'fulfilled') { + throw new Error('Expected the /context request to fail with a 500'); + } else if (settledFailedRefreshPromises[0].reason.errcode !== 'TEST_FAKE_ERROR') { + throw settledFailedRefreshPromises[0].reason; + } + + // The timeline will be empty after we refresh the timeline and fail + // to construct a new timeline. + expect(room.timeline.length).toEqual(0); + + // `/messages` request for `refreshLiveTimeline()` -> + // `getLatestTimeline()` to construct a new timeline from. + httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) + .respond(200, function() { + return { + chunk: [{ + // The latest message in the room + event_id: initialSyncEventData[2].event_id, + }], + }; + }); + // `/context` request for `refreshLiveTimeline()` -> + // `getLatestTimeline()` -> `getEventTimeline()` to construct a new + // timeline from. + httpBackend.when("GET", contextUrl) + .respond(200, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return contextResponse; + }); + + // Refresh the timeline again but this time it should pass + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Make sure sync pagination still works by seeing a new message show up + // after refreshing the timeline. + const afterRefreshEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + setNextSyncData(afterRefreshEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Make sure the message are visible + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + initialSyncEventData[0].event_id, + initialSyncEventData[1].event_id, + initialSyncEventData[2].event_id, + afterRefreshEventData[0].event_id, + ]); + }); + }); }); diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js index 7c33f0948ea..0c571707ad3 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventTimeline, MatrixEvent, RoomEvent } from "../../src"; +import { EventTimeline, MatrixEvent, RoomEvent, RoomStateEvent, RoomMemberEvent } from "../../src"; +import { UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -76,7 +77,7 @@ describe("MatrixClient syncing", function() { }); }); - it("should emit Room.myMembership for invite->leave->invite cycles", async () => { + it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => { const roomId = "!cycles:example.org"; // First sync: an invite @@ -298,7 +299,7 @@ describe("MatrixClient syncing", function() { httpBackend.when("GET", "/sync").respond(200, syncData); let latestFiredName = null; - client.on("RoomMember.name", function(event, m) { + client.on(RoomMemberEvent.Name, function(event, m) { if (m.userId === userC && m.roomId === roomOne) { latestFiredName = m.name; } @@ -582,6 +583,477 @@ describe("MatrixClient syncing", function() { xit("should update the room topic", function() { }); + + describe("onMarkerStateEvent", () => { + const normalMessageEvent = utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }); + + it('new marker event *NOT* from the room creator in a subsequent syncs ' + + 'should *NOT* mark the timeline as needing a refresh', async () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + room_version: '9', + }, + }); + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + normalFirstSync.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should normally trigger + // `timelineNeedsRefresh=true` but this marker isn't + // being sent by the room creator so it has no + // special meaning in existing room versions. + utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, + room: roomOne, + // The important part we're testing is here! + // `userC` is not the room creator. + user: userC, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, + }; + + // Ensure the marker is being sent by someone who is not the room creator + // because this is the main thing we're testing in this spec. + const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0]; + expect(markerEvent.sender).toBeDefined(); + expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender); + + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + [{ + label: 'In existing room versions (when the room creator sends the MSC2716 events)', + roomVersion: '9', + }, { + label: 'In a MSC2716 supported room version', + roomVersion: 'org.matrix.msc2716v3', + }].forEach((testMeta) => { + describe(testMeta.label, () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + room_version: testMeta.roomVersion, + }, + }); + + const markerEventFromRoomCreator = utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, room: roomOne, user: otherUserId, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }); + + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + normalFirstSync.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + it('no marker event in sync response '+ + 'should *NOT* mark the timeline as needing a refresh (check for a sane default)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('marker event already sent within timeline range when you join ' + + 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [markerEventFromRoomCreator], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('marker event already sent before joining (in state) ' + + 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [ + roomCreateEvent, + markerEventFromRoomCreator, + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('new marker event in a subsequent syncs timeline range ' + + 'should mark the timeline as needing a refresh', async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + prev_batch: "pagTok", + }, + }; + + const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id; + + // Only do the first sync + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + + let emitCount = 0; + room.on(RoomEvent.HistoryImportedWithinTimeline, function(markerEvent, room) { + expect(markerEvent.getId()).toEqual(markerEventId); + expect(room.roomId).toEqual(roomOne); + emitCount += 1; + }); + + // Now do a subsequent sync with the marker event + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + expect(room.getTimelineNeedsRefresh()).toEqual(true); + // Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted + expect(emitCount).toEqual(1); + }); + + // Mimic a marker event being sent far back in the scroll back but since our last sync + it('new marker event in sync state should mark the timeline as needing a refresh', async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello again", + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + // In subsequent syncs, a marker event in state + // should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(true); + }); + }); + }); + }); + + // Make sure the state listeners work and events are re-emitted properly from + // the client regardless if we reset and refresh the timeline. + describe('state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired', () => { + const EVENTS = [ + utils.mkMessage({ + room: roomOne, user: userA, msg: "we", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "could", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "be", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "heroes", + }), + ]; + + const SOME_STATE_EVENT = utils.mkEvent({ + event: true, + type: 'org.matrix.test_state', + room: roomOne, + user: userA, + skey: "", + content: { + "foo": "bar", + }, + }); + + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ + room: roomOne, mship: "join", user: userA, + }); + + // This appears to work even if we comment out + // `RoomEvent.CurrentStateUpdated` part which triggers everything to + // re-listen after the `room.currentState` reference changes. I'm + // not sure how it's getting re-emitted. + it("should be able to listen to state events even after " + + "the timeline is reset during `limited` sync response", async () => { + // Create a room from the sync + httpBackend.when("GET", "/sync").respond(200, syncData); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); + + // Make a `limited` sync which will cause a `room.resetLiveTimeline` + const limitedSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + limitedSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + // The important part, make the sync `limited` + limited: true, + prev_batch: "newerTok", + }, + }; + httpBackend.when("GET", "/sync").respond(200, limitedSyncData); + + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // This got incremented again from processing the sync response + expect(stateEventEmitCount).toEqual(2); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(3); + }); + + // Make sure it re-registers the state listeners after the + // `room.currentState` reference changes + it("should be able to listen to state events even after " + + "refreshing the timeline", async () => { + const testClientWithTimelineSupport = new TestClient( + selfUserId, + "DEVICE", + selfAccessToken, + undefined, + { timelineSupport: true }, + ); + httpBackend = testClientWithTimelineSupport.httpBackend; + httpBackend.when("GET", "/versions").respond(200, {}); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + client = testClientWithTimelineSupport.client; + + // Create a room from the sync + httpBackend.when("GET", "/sync").respond(200, syncData); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); + + const eventsInRoom = syncData.rooms.join[roomOne].timeline.events; + const contextUrl = `/rooms/${encodeURIComponent(roomOne)}/context/` + + `${encodeURIComponent(eventsInRoom[0].event_id)}`; + httpBackend.when("GET", contextUrl) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + // Refresh the timeline. This will cause the `room.currentState` + // reference to change + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(2); + }); + }); }); describe("timeline", function() { @@ -637,7 +1109,7 @@ describe("MatrixClient syncing", function() { awaitSyncEvent(), ]).then(function() { const room = client.getRoom(roomTwo); - expect(room).toBeDefined(); + expect(room).toBeTruthy(); const tok = room.getLiveTimeline() .getPaginationToken(EventTimeline.BACKWARDS); expect(tok).toEqual("roomtwotok"); @@ -666,7 +1138,7 @@ describe("MatrixClient syncing", function() { let resetCallCount = 0; // the token should be set *before* timelineReset is emitted - client.on("Room.timelineReset", function(room) { + client.on(RoomEvent.TimelineReset, function(room) { resetCallCount++; const tl = room.getLiveTimeline(); diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 82cadddf8f5..eaa723940b9 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -23,6 +23,7 @@ import { MatrixEvent, MatrixEventEvent, Room, + DuplicateStrategy, } from '../../src'; describe('EventTimelineSet', () => { @@ -73,6 +74,76 @@ describe('EventTimelineSet', () => { }) as MatrixEvent; }); + describe('addLiveEvent', () => { + it("Adds event to the live timeline in the timeline set", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addLiveEvent(messageEvent); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + }); + + it("should replace a timeline event if dupe strategy is 'replace'", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addLiveEvent(messageEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + + // make a duplicate + const duplicateMessageEvent = utils.mkMessage({ + room: roomId, user: userA, msg: "dupe", event: true, + }) as MatrixEvent; + duplicateMessageEvent.event.event_id = messageEvent.getId(); + + // Adding the duplicate event should replace the `messageEvent` + // because it has the same `event_id` and duplicate strategy is + // replace. + eventTimelineSet.addLiveEvent(duplicateMessageEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + }); + + const eventsInLiveTimeline = liveTimeline.getEvents(); + expect(eventsInLiveTimeline.length).toStrictEqual(1); + expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent); + }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow(); + expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow(); + }); + }); + + describe('addEventToTimeline', () => { + it("Adds event to timeline", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(() => { + eventTimelineSet.addEventToTimeline( + messageEvent, + liveTimeline, + true, + ); + }).not.toThrow(); + expect(() => { + eventTimelineSet.addEventToTimeline( + messageEvent, + liveTimeline, + true, + false, + ); + }).not.toThrow(); + }); + }); + describe('aggregateRelations', () => { describe('with unencrypted events', () => { beforeEach(() => { diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index c9311d0e387..ed5047c111e 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -50,9 +50,11 @@ describe("EventTimeline", function() { timeline.initialiseState(events); expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( events, + { timelineWasEmpty: undefined }, ); expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( events, + { timelineWasEmpty: undefined }, ); }); @@ -73,7 +75,7 @@ describe("EventTimeline", function() { expect(function() { timeline.initialiseState(state); }).not.toThrow(); - timeline.addEvent(event, false); + timeline.addEvent(event, { toStartOfTimeline: false }); expect(function() { timeline.initialiseState(state); }).toThrow(); @@ -149,9 +151,9 @@ describe("EventTimeline", function() { ]; it("should be able to add events to the end", function() { - timeline.addEvent(events[0], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], false); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[0]); @@ -159,9 +161,9 @@ describe("EventTimeline", function() { }); it("should be able to add events to the start", function() { - timeline.addEvent(events[0], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], true); + timeline.addEvent(events[1], { toStartOfTimeline: true }); expect(timeline.getBaseIndex()).toEqual(initialIndex + 1); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[1]); @@ -203,9 +205,9 @@ describe("EventTimeline", function() { content: { name: "Old Room Name" }, }); - timeline.addEvent(newEv, false); + timeline.addEvent(newEv, { toStartOfTimeline: false }); expect(newEv.sender).toEqual(sentinel); - timeline.addEvent(oldEv, true); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); expect(oldEv.sender).toEqual(oldSentinel); }); @@ -242,9 +244,9 @@ describe("EventTimeline", function() { const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, }); - timeline.addEvent(newEv, false); + timeline.addEvent(newEv, { toStartOfTimeline: false }); expect(newEv.target).toEqual(sentinel); - timeline.addEvent(oldEv, true); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); expect(oldEv.target).toEqual(oldSentinel); }); @@ -262,13 +264,13 @@ describe("EventTimeline", function() { }), ]; - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[0]]); + toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -291,13 +293,13 @@ describe("EventTimeline", function() { }), ]; - timeline.addEvent(events[0], true); - timeline.addEvent(events[1], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[0]]); + toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -305,6 +307,11 @@ describe("EventTimeline", function() { expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). not.toHaveBeenCalled(); }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow(); + expect(() => timeline.addEvent(events[0], { stateContext: new RoomState() })).not.toThrow(); + }); }); describe("removeEvent", function() { @@ -324,8 +331,8 @@ describe("EventTimeline", function() { ]; it("should remove events", function() { - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(2); let ev = timeline.removeEvent(events[0].getId()); @@ -338,9 +345,9 @@ describe("EventTimeline", function() { }); it("should update baseIndex", function() { - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], true); - timeline.addEvent(events[2], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(3); expect(timeline.getBaseIndex()).toEqual(1); @@ -358,11 +365,11 @@ describe("EventTimeline", function() { // further addEvent(ev, false) calls made the index increase. it("should not make baseIndex assplode when removing the last event", function() { - timeline.addEvent(events[0], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); timeline.removeEvent(events[0].getId()); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], false); - timeline.addEvent(events[2], false); + timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); }); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index b353b7aa36e..b54121431bb 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -3,7 +3,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; -import { EventType, RelationType } from "../../src/@types/event"; +import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { MatrixEvent, MatrixEventEvent, @@ -258,6 +258,29 @@ describe("RoomState", function() { ); }); + it("should emit `RoomStateEvent.Marker` for each marker event", function() { + const events = [ + utils.mkEvent({ + event: true, + type: UNSTABLE_MSC2716_MARKER.name, + room: roomId, + user: userA, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ]; + let emitCount = 0; + state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) { + expect(markerEvent).toEqual(events[emitCount]); + expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); + emitCount += 1; + }); + state.setStateEvents(events, { timelineWasEmpty: true }); + expect(emitCount).toEqual(1); + }); + describe('beacon events', () => { it('adds new beacon info events to state and emits', () => { const beaconEvent = makeBeaconInfoEvent(userA, roomId); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 54d0f41dcb4..921feae1af4 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -133,6 +133,27 @@ describe("Room", function() { room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); + describe('getCreator', () => { + it("should return the creator from m.room.create", function() { + room.currentState.getStateEvents.mockImplementation(function(type, key) { + if (type === EventType.RoomCreate && key === "") { + return utils.mkEvent({ + event: true, + type: EventType.RoomCreate, + skey: "", + room: roomId, + user: userA, + content: { + creator: userA, + }, + }); + } + }); + const roomCreator = room.getCreator(); + expect(roomCreator).toStrictEqual(userA); + }); + }); + describe("getAvatarUrl", function() { const hsUrl = "https://my.home.server"; @@ -196,22 +217,17 @@ describe("Room", function() { }) as MatrixEvent, ]; - it("should call RoomState.setTypingEvent on m.typing events", function() { - const typing = utils.mkEvent({ - room: roomId, - type: EventType.Typing, - event: true, - content: { - user_ids: [userA], - }, - }); - room.addEphemeralEvents([typing]); - expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow(); + expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow(); + expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow(); }); it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { expect(function() { - room.addLiveEvents(events, "foo"); + room.addLiveEvents(events, { + duplicateStrategy: "foo", + }); }).toThrow(); }); @@ -223,7 +239,9 @@ describe("Room", function() { dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], DuplicateStrategy.Replace); + room.addLiveEvents([dupe], { + duplicateStrategy: DuplicateStrategy.Replace, + }); expect(room.timeline[0]).toEqual(dupe); }); @@ -235,7 +253,9 @@ describe("Room", function() { dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], "ignore"); + room.addLiveEvents([dupe], { + duplicateStrategy: "ignore", + }); expect(room.timeline[0]).toEqual(events[0]); }); @@ -268,9 +288,11 @@ describe("Room", function() { room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { timelineWasEmpty: undefined }, ); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { timelineWasEmpty: undefined }, ); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -341,6 +363,21 @@ describe("Room", function() { }); }); + describe('addEphemeralEvents', () => { + it("should call RoomState.setTypingEvent on m.typing events", function() { + const typing = utils.mkEvent({ + room: roomId, + type: EventType.Typing, + event: true, + content: { + user_ids: [userA], + }, + }); + room.addEphemeralEvents([typing]); + expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + }); + }); + describe("addEventsToTimeline", function() { const events = [ utils.mkMessage({ @@ -472,9 +509,11 @@ describe("Room", function() { room.addEventsToTimeline(events, true, room.getLiveTimeline()); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { timelineWasEmpty: undefined }, ); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { timelineWasEmpty: undefined }, ); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -520,6 +559,23 @@ describe("Room", function() { it("should reset the legacy timeline fields", function() { room.addLiveEvents([events[0], events[1]]); expect(room.timeline.length).toEqual(2); + + const oldStateBeforeRunningReset = room.oldState; + let oldStateUpdateEmitCount = 0; + room.on(RoomEvent.OldStateUpdated, function(room, previousOldState, oldState) { + expect(previousOldState).toBe(oldStateBeforeRunningReset); + expect(oldState).toBe(room.oldState); + oldStateUpdateEmitCount += 1; + }); + + const currentStateBeforeRunningReset = room.currentState; + let currentStateUpdateEmitCount = 0; + room.on(RoomEvent.CurrentStateUpdated, function(room, previousCurrentState, currentState) { + expect(previousCurrentState).toBe(currentStateBeforeRunningReset); + expect(currentState).toBe(room.currentState); + currentStateUpdateEmitCount += 1; + }); + room.resetLiveTimeline('sometoken', 'someothertoken'); room.addLiveEvents([events[2]]); @@ -529,6 +585,10 @@ describe("Room", function() { newLiveTimeline.getState(EventTimeline.BACKWARDS)); expect(room.currentState).toEqual( newLiveTimeline.getState(EventTimeline.FORWARDS)); + // Make sure `RoomEvent.OldStateUpdated` was emitted + expect(oldStateUpdateEmitCount).toEqual(1); + // Make sure `RoomEvent.OldStateUpdated` was emitted if necessary + expect(currentStateUpdateEmitCount).toEqual(timelineSupport ? 1 : 0); }); it("should emit Room.timelineReset event and set the correct " + diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index c9466412c83..4fc78234446 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -35,13 +35,14 @@ function createTimeline(numEvents, baseIndex) { return timeline; } -function addEventsToTimeline(timeline, numEvents, atStart) { +function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) { for (let i = 0; i < numEvents; i++) { timeline.addEvent( utils.mkMessage({ room: ROOM_ID, user: USER_ID, event: true, - }), atStart, + }), + { toStartOfTimeline }, ); } } diff --git a/src/@types/event.ts b/src/@types/event.ts index e5eac34f948..dac2770ade3 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -151,6 +151,14 @@ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc */ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); +/** + * Marker event type to point back at imported historical content in a room. See + * [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716). + * Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker"); + /** * Functional members type for declaring a purpose of room members (e.g. helpful bots). * Note that this reference is UNSTABLE and subject to breaking changes, including its diff --git a/src/client.ts b/src/client.ts index 04555b7c16b..e0f40a8a2ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -815,6 +815,7 @@ type RoomEvents = RoomEvent.Name | RoomEvent.Receipt | RoomEvent.Tags | RoomEvent.LocalEchoUpdated + | RoomEvent.HistoryImportedWithinTimeline | RoomEvent.AccountData | RoomEvent.MyMembership | RoomEvent.Timeline @@ -824,6 +825,7 @@ type RoomStateEvents = RoomStateEvent.Events | RoomStateEvent.Members | RoomStateEvent.NewMember | RoomStateEvent.Update + | RoomStateEvent.Marker ; type CryptoEvents = CryptoEvent.KeySignatureUploadFailure @@ -5309,7 +5311,8 @@ export class MatrixClient extends TypedEventEmitter { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable it."); + } + + const messagesPath = utils.encodeUri( + "/rooms/$roomId/messages", { + $roomId: timelineSet.room.roomId, + }, + ); + + const params: Record = { + dir: 'b', + }; + if (this.clientOpts.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + const res = await this.http.authedRequest(undefined, Method.Get, messagesPath, params); + const event = res.chunk?.[0]; + if (!event) { + throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); + } + + return this.getEventTimeline(timelineSet, event.event_id); + } + /** * Makes a request to /messages with the appropriate lazy loading filter set. * XXX: if we do get rid of scrollback (as it's not used at the moment), diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 8e5049c5f09..9b3273d929e 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -18,7 +18,7 @@ limitations under the License. * @module models/event-timeline-set */ -import { EventTimeline } from "./event-timeline"; +import { EventTimeline, IAddEventOptions } from "./event-timeline"; import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event"; import { logger } from '../logger'; import { Relations } from './relations'; @@ -55,6 +55,23 @@ export interface IRoomTimelineData { liveEvent?: boolean; } +export interface IAddEventToTimelineOptions + extends Pick { + /** Whether the sync response came from cache */ + fromCache?: boolean; +} + +export interface IAddLiveEventOptions + extends Pick { + /** Applies to events in the timeline only. If this is 'replace' then if a + * duplicate is encountered, the event passed to this function will replace + * the existing event in the timeline. If this is not specified, or is + * 'ignore', then the event passed to this function will be ignored + * entirely, preserving the existing event in the timeline. Events are + * identical based on their event ID only. */ + duplicateStrategy?: DuplicateStrategy; +} + type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; export type EventTimelineSetHandlerMap = { @@ -180,6 +197,15 @@ export class EventTimelineSet extends TypedEventEmitter { + // This is a separate interface without any extra stuff currently added on + // top of `IMarkerFoundOptions` just because it feels like they have + // different concerns. One shouldn't necessarily look to add to + // `IMarkerFoundOptions` just because they want to add an extra option to + // `initialiseState`. +} + +export interface IAddEventOptions extends Pick { + /** Whether to insert the new event at the start of the timeline where the + * oldest events are (timeline is in chronological order, oldest to most + * recent) */ + toStartOfTimeline: boolean; + /** The state events to reconcile metadata from */ + roomState?: RoomState; +} + export enum Direction { Backward = "b", Forward = "f", @@ -131,7 +149,7 @@ export class EventTimeline { * state with. * @throws {Error} if an attempt is made to call this after addEvent is called. */ - public initialiseState(stateEvents: MatrixEvent[]): void { + public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void { if (this.events.length > 0) { throw new Error("Cannot initialise state after events are added"); } @@ -152,8 +170,12 @@ export class EventTimeline { Object.freeze(e); } - this.startState.setStateEvents(stateEvents); - this.endState.setStateEvents(stateEvents); + this.startState.setStateEvents(stateEvents, { + timelineWasEmpty, + }); + this.endState.setStateEvents(stateEvents, { + timelineWasEmpty, + }); } /** @@ -345,24 +367,60 @@ export class EventTimeline { * Add a new event to the timeline, and update the state * * @param {MatrixEvent} event new event - * @param {boolean} atStart true to insert new event at the start + * @param {IAddEventOptions} options addEvent options */ - public addEvent(event: MatrixEvent, atStart: boolean, stateContext?: RoomState): void { - if (!stateContext) { - stateContext = atStart ? this.startState : this.endState; + public addEvent( + event: MatrixEvent, + { + toStartOfTimeline, + roomState, + timelineWasEmpty, + }: IAddEventOptions, + ): void; + /** + * @deprecated In favor of the overload with `IAddEventOptions` + */ + public addEvent( + event: MatrixEvent, + toStartOfTimeline: boolean, + roomState?: RoomState + ): void; + public addEvent( + event: MatrixEvent, + toStartOfTimelineOrOpts: boolean | IAddEventOptions, + roomState?: RoomState, + ): void { + let toStartOfTimeline = !!toStartOfTimelineOrOpts; + let timelineWasEmpty: boolean; + if (typeof (toStartOfTimelineOrOpts) === 'object') { + ({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts); + } else if (toStartOfTimelineOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + 'Overload deprecated: ' + + '`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` ' + + 'is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`', + ); + } + + if (!roomState) { + roomState = toStartOfTimeline ? this.startState : this.endState; } const timelineSet = this.getTimelineSet(); if (timelineSet.room) { - EventTimeline.setEventMetadata(event, stateContext, atStart); + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); // modify state but only on unfiltered timelineSets if ( event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet ) { - stateContext.setStateEvents([event]); + roomState.setStateEvents([event], { + timelineWasEmpty, + }); // it is possible that the act of setting the state event means we // can set more metadata (specifically sender/target props), so try // it again if the prop wasn't previously set. It may also mean that @@ -373,22 +431,22 @@ export class EventTimeline { // back in time, else we'll set the .sender value for BEFORE the given // member event, whereas we want to set the .sender value for the ACTUAL // member event itself. - if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { - EventTimeline.setEventMetadata(event, stateContext, atStart); + if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) { + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); } } } let insertIndex; - if (atStart) { + if (toStartOfTimeline) { insertIndex = 0; } else { insertIndex = this.events.length; } this.events.splice(insertIndex, 0, event); // insert element - if (atStart) { + if (toStartOfTimeline) { this.baseIndex++; } } diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 30b87f487b9..02632b1aece 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -21,7 +21,7 @@ limitations under the License. import { RoomMember } from "./room-member"; import { logger } from '../logger'; import * as utils from "../utils"; -import { EventType } from "../@types/event"; +import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event"; import { MatrixEvent, MatrixEventEvent } from "./event"; import { MatrixClient } from "../client"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; @@ -30,6 +30,22 @@ import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, Be import { TypedReEmitter } from "../ReEmitter"; import { M_BEACON, M_BEACON_INFO } from "../@types/beacon"; +export interface IMarkerFoundOptions { + /** Whether the timeline was empty before the marker event arrived in the + * room. This could be happen in a variety of cases: + * 1. From the initial sync + * 2. It's the first state we're seeing after joining the room + * 3. Or whether it's coming from `syncFromCache` + * + * A marker event refers to `UNSTABLE_MSC2716_MARKER` and indicates that + * history was imported somewhere back in time. It specifically points to an + * MSC2716 insertion event where the history was imported at. Marker events + * are sent as state events so they are easily discoverable by clients and + * homeservers and don't get lost in timeline gaps. + */ + timelineWasEmpty?: boolean; +} + // possible statuses for out-of-band member loading enum OobStatus { NotStarted, @@ -43,6 +59,7 @@ export enum RoomStateEvent { NewMember = "RoomState.newMember", Update = "RoomState.update", // signals batches of updates without specificity BeaconLiveness = "RoomState.BeaconLiveness", + Marker = "RoomState.Marker", } export type RoomStateEventHandlerMap = { @@ -51,6 +68,7 @@ export type RoomStateEventHandlerMap = { [RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; [RoomStateEvent.Update]: (state: RoomState) => void; [RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void; + [RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions: IMarkerFoundOptions) => void; [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void; }; @@ -314,16 +332,19 @@ export class RoomState extends TypedEventEmitter } /** - * Add an array of one or more state MatrixEvents, overwriting - * any existing state with the same {type, stateKey} tuple. Will fire - * "RoomState.events" for every event added. May fire "RoomState.members" - * if there are m.room.member events. + * Add an array of one or more state MatrixEvents, overwriting any existing + * state with the same {type, stateKey} tuple. Will fire "RoomState.events" + * for every event added. May fire "RoomState.members" if there are + * m.room.member events. May fire "RoomStateEvent.Marker" if there are + * UNSTABLE_MSC2716_MARKER events. * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @param {IMarkerFoundOptions} markerFoundOptions * @fires module:client~MatrixClient#event:"RoomState.members" * @fires module:client~MatrixClient#event:"RoomState.newMember" * @fires module:client~MatrixClient#event:"RoomState.events" + * @fires module:client~MatrixClient#event:"RoomStateEvent.Marker" */ - public setStateEvents(stateEvents: MatrixEvent[]) { + public setStateEvents(stateEvents: MatrixEvent[], markerFoundOptions?: IMarkerFoundOptions) { this.updateModifiedTime(); // update the core event dict @@ -403,6 +424,8 @@ export class RoomState extends TypedEventEmitter // assume all our sentinels are now out-of-date this.sentinels = {}; + } else if (UNSTABLE_MSC2716_MARKER.matches(event.getType())) { + this.emit(RoomStateEvent.Marker, event, markerFoundOptions); } }); diff --git a/src/models/room.ts b/src/models/room.ts index 6e286e794c8..eeb2325f536 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -18,7 +18,7 @@ limitations under the License. * @module models/room */ -import { EventTimelineSet, DuplicateStrategy } from "./event-timeline-set"; +import { EventTimelineSet, DuplicateStrategy, IAddLiveEventOptions } from "./event-timeline-set"; import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; @@ -165,6 +165,10 @@ export enum RoomEvent { LocalEchoUpdated = "Room.localEchoUpdated", Timeline = "Room.timeline", TimelineReset = "Room.timelineReset", + TimelineRefresh = "Room.TimelineRefresh", + OldStateUpdated = "Room.OldStateUpdated", + CurrentStateUpdated = "Room.CurrentStateUpdated", + HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", } type EmittedEvents = RoomEvent @@ -173,6 +177,10 @@ type EmittedEvents = RoomEvent | ThreadEvent.NewReply | RoomEvent.Timeline | RoomEvent.TimelineReset + | RoomEvent.TimelineRefresh + | RoomEvent.HistoryImportedWithinTimeline + | RoomEvent.OldStateUpdated + | RoomEvent.CurrentStateUpdated | MatrixEventEvent.BeforeRedaction; export type RoomEventHandlerMap = { @@ -189,6 +197,13 @@ export type RoomEventHandlerMap = { oldEventId?: string, oldStatus?: EventStatus, ) => void; + [RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; + [RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; + [RoomEvent.HistoryImportedWithinTimeline]: ( + markerEvent: MatrixEvent, + room: Room, + ) => void; + [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; } & ThreadHandlerMap & MatrixEventHandlerMap; @@ -206,6 +221,7 @@ export class Room extends TypedEventEmitter public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet + private timelineNeedsRefresh = false; private readonly pendingEventList?: MatrixEvent[]; // read by megolm via getter; boolean value - null indicates "use global value" private blacklistUnverifiedDevices: boolean = null; @@ -441,6 +457,15 @@ export class Room extends TypedEventEmitter return Promise.allSettled(decryptionPromises) as unknown as Promise; } + /** + * Gets the creator of the room + * @returns {string} The creator of the room, or null if it could not be determined + */ + public getCreator(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + return createEvent?.getContent()['creator'] ?? null; + } + /** * Gets the version of the room * @returns {string} The version of the room, or null if it could not be determined @@ -897,6 +922,108 @@ export class Room extends TypedEventEmitter }); } + /** + * Empty out the current live timeline and re-request it. This is used when + * historical messages are imported into the room via MSC2716 `/batch_send + * because the client may already have that section of the timeline loaded. + * We need to force the client to throw away their current timeline so that + * when they back paginate over the area again with the historical messages + * in between, it grabs the newly imported messages. We can listen for + * `UNSTABLE_MSC2716_MARKER`, in order to tell when historical messages are ready + * to be discovered in the room and the timeline needs a refresh. The SDK + * emits a `RoomEvent.HistoryImportedWithinTimeline` event when we detect a + * valid marker and can check the needs refresh status via + * `room.getTimelineNeedsRefresh()`. + */ + public async refreshLiveTimeline(): Promise { + const liveTimelineBefore = this.getLiveTimeline(); + const forwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.FORWARDS); + const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS); + const eventsBefore = liveTimelineBefore.getEvents(); + const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; + logger.log( + `[refreshLiveTimeline for ${this.roomId}] at ` + + `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + + `liveTimelineBefore=${liveTimelineBefore.toString()} ` + + `forwardPaginationToken=${forwardPaginationToken} ` + + `backwardPaginationToken=${backwardPaginationToken}`, + ); + + // Get the main TimelineSet + const timelineSet = this.getUnfilteredTimelineSet(); + + let newTimeline: EventTimeline; + // If there isn't any event in the timeline, let's go fetch the latest + // event and construct a timeline from it. + // + // This should only really happen if the user ran into an error + // with refreshing the timeline before which left them in a blank + // timeline from `resetLiveTimeline`. + if (!mostRecentEventInTimeline) { + newTimeline = await this.client.getLatestTimeline(timelineSet); + } else { + // Empty out all of `this.timelineSets`. But we also need to keep the + // same `timelineSet` references around so the React code updates + // properly and doesn't ignore the room events we emit because it checks + // that the `timelineSet` references are the same. We need the + // `timelineSet` empty so that the `client.getEventTimeline(...)` call + // later, will call `/context` and create a new timeline instead of + // returning the same one. + this.resetLiveTimeline(null, null); + + // Make the UI timeline show the new blank live timeline we just + // reset so that if the network fails below it's showing the + // accurate state of what we're working with instead of the + // disconnected one in the TimelineWindow which is just hanging + // around by reference. + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + + // Use `client.getEventTimeline(...)` to construct a new timeline from a + // `/context` response state and events for the most recent event before + // we reset everything. The `timelineSet` we pass in needs to be empty + // in order for this function to call `/context` and generate a new + // timeline. + newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId()); + } + + // If a racing `/sync` beat us to creating a new timeline, use that + // instead because it's the latest in the room and any new messages in + // the scrollback will include the history. + const liveTimeline = timelineSet.getLiveTimeline(); + if (!liveTimeline || ( + liveTimeline.getPaginationToken(Direction.Forward) === null && + liveTimeline.getPaginationToken(Direction.Backward) === null && + liveTimeline.getEvents().length === 0 + )) { + logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`); + // Set the pagination token back to the live sync token (`null`) instead + // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`) + // so that it matches the next response from `/sync` and we can properly + // continue the timeline. + newTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS); + + // Set our new fresh timeline as the live timeline to continue syncing + // forwards and back paginating from. + timelineSet.setLiveTimeline(newTimeline); + // Fixup `this.oldstate` so that `scrollback` has the pagination tokens + // available + this.fixUpLegacyTimelineFields(); + } else { + logger.log( + `[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` + + `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` + + `this timeline will include the history.`, + ); + } + + // The timeline has now been refreshed ✅ + this.setTimelineNeedsRefresh(false); + + // Emit an event which clients can react to and re-load the timeline + // from the SDK + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + } + /** * Reset the live timeline of all timelineSets, and start new ones. * @@ -924,6 +1051,9 @@ export class Room extends TypedEventEmitter * @private */ private fixUpLegacyTimelineFields(): void { + const previousOldState = this.oldState; + const previousCurrentState = this.currentState; + // maintain this.timeline as a reference to the live timeline, // and this.oldState and this.currentState as references to the // state at the start and end of that timeline. These are more @@ -933,6 +1063,17 @@ export class Room extends TypedEventEmitter .getState(EventTimeline.BACKWARDS); this.currentState = this.getLiveTimeline() .getState(EventTimeline.FORWARDS); + + // Let people know to register new listeners for the new state + // references. The reference won't necessarily change every time so only + // emit when we see a change. + if (previousOldState !== this.oldState) { + this.emit(RoomEvent.OldStateUpdated, this, previousOldState, this.oldState); + } + + if (previousCurrentState !== this.currentState) { + this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState); + } } /** @@ -1000,6 +1141,24 @@ export class Room extends TypedEventEmitter return this.getUnfilteredTimelineSet().addTimeline(); } + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @param {Boolean} value The value to set + */ + public setTimelineNeedsRefresh(value: boolean): void { + this.timelineNeedsRefresh = value; + } + + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @return {Boolean} . + */ + public getTimelineNeedsRefresh(): boolean { + return this.timelineNeedsRefresh; + } + /** * Get an event which is stored in our unfiltered timeline set, or in a thread * @@ -1454,7 +1613,9 @@ export class Room extends TypedEventEmitter return event.getSender() === this.client.getUserId(); }); if (filterType !== ThreadFilterType.My || currentUserParticipated) { - timelineSet.getLiveTimeline().addEvent(thread.rootEvent, false); + timelineSet.getLiveTimeline().addEvent(thread.rootEvent, { + toStartOfTimeline: false, + }); } }); } @@ -1501,22 +1662,20 @@ export class Room extends TypedEventEmitter let latestMyThreadsRootEvent: MatrixEvent; const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); for (const rootEvent of threadRoots) { - this.threadsTimelineSets[0].addLiveEvent( - rootEvent, - DuplicateStrategy.Ignore, - false, + this.threadsTimelineSets[0].addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, roomState, - ); + }); const threadRelationship = rootEvent .getServerAggregatedRelation(RelationType.Thread); if (threadRelationship.current_user_participated) { - this.threadsTimelineSets[1].addLiveEvent( - rootEvent, - DuplicateStrategy.Ignore, - false, + this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, roomState, - ); + }); latestMyThreadsRootEvent = rootEvent; } @@ -1778,15 +1937,20 @@ export class Room extends TypedEventEmitter * "Room.timeline". * * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache + * @param {IAddLiveEventOptions} options addLiveEvent options * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ - private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void { + private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void { + const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions; + // add to our timeline sets for (let i = 0; i < this.timelineSets.length; i++) { - this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); + this.timelineSets[i].addLiveEvent(event, { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }); } // synthesize and inject implicit read receipts @@ -1872,11 +2036,15 @@ export class Room extends TypedEventEmitter if (timelineSet.getFilter()) { if (timelineSet.getFilter().filterRoomTimeline([event]).length) { timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); + timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); } } else { timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); + timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); } } } @@ -2113,18 +2281,38 @@ export class Room extends TypedEventEmitter * they will go to the end of the timeline. * * @param {MatrixEvent[]} events A list of events to add. - * - * @param {string} duplicateStrategy Optional. Applies to events in the - * timeline only. If this is 'replace' then if a duplicate is encountered, the - * event passed to this function will replace the existing event in the - * timeline. If this is not specified, or is 'ignore', then the event passed to - * this function will be ignored entirely, preserving the existing event in the - * timeline. Events are identical based on their event ID only. - * - * @param {boolean} fromCache whether the sync response came from cache + * @param {IAddLiveEventOptions} options addLiveEvent options * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ - public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { + public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void; + /** + * @deprecated In favor of the overload with `IAddLiveEventOptions` + */ + public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void; + public addLiveEvents( + events: MatrixEvent[], + duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, + fromCache = false, + ): void { + let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy; + let timelineWasEmpty: boolean; + if (typeof (duplicateStrategyOrOpts) === 'object') { + ({ + duplicateStrategy, + fromCache = false, + /* roomState, (not used here) */ + timelineWasEmpty, + } = duplicateStrategyOrOpts); + } else if (duplicateStrategyOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + 'Overload deprecated: ' + + '`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` ' + + 'is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`', + ); + } + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } @@ -2162,7 +2350,11 @@ export class Room extends TypedEventEmitter eventsByThread[threadId]?.push(event); if (shouldLiveInRoom) { - this.addLiveEvent(event, duplicateStrategy, fromCache); + this.addLiveEvent(event, { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }); } } diff --git a/src/models/thread.ts b/src/models/thread.ts index 6ac5c985d4d..4fb8bed4212 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -199,9 +199,11 @@ export class Thread extends TypedEventEmitter { this.timelineSet.addEventToTimeline( event, this.liveTimeline, - toStartOfTimeline, - false, - this.roomState, + { + toStartOfTimeline, + fromCache: false, + roomState: this.roomState, + }, ); } } diff --git a/src/sync.ts b/src/sync.ts index 794fd88b250..69da493eb0f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -51,7 +51,7 @@ import { MatrixError, Method } from "./http-api"; import { ISavedSync } from "./store"; import { EventType } from "./@types/event"; import { IPushRules } from "./@types/PushRules"; -import { RoomStateEvent } from "./models/room-state"; +import { RoomState, RoomStateEvent, IMarkerFoundOptions } from "./models/room-state"; import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; import { IEventsResponse } from "./@types/requests"; @@ -71,14 +71,32 @@ const BUFFER_PERIOD_MS = 80 * 1000; const FAILED_SYNC_ERROR_THRESHOLD = 3; export enum SyncState { + /** Emitted after we try to sync more than `FAILED_SYNC_ERROR_THRESHOLD` + * times and are still failing. Or when we enounter a hard error like the + * token being invalid. */ Error = "ERROR", + /** Emitted after the first sync events are ready (this could even be sync + * events from the cache) */ Prepared = "PREPARED", + /** Emitted when the sync loop is no longer running */ Stopped = "STOPPED", + /** Emitted after each sync request happens */ Syncing = "SYNCING", + /** Emitted after a connectivity error and we're ready to start syncing again */ Catchup = "CATCHUP", + /** Emitted for each time we try reconnecting. Will switch to `Error` after + * we reach the `FAILED_SYNC_ERROR_THRESHOLD` + */ Reconnecting = "RECONNECTING", } +// Room versions where "insertion", "batch", and "marker" events are controlled +// by power-levels. MSC2716 is supported in existing room versions but they +// should only have special meaning when the room creator sends them. +const MSC2716_ROOM_VERSIONS = [ + 'org.matrix.msc2716v3', +]; + function getFilterName(userId: string, suffix?: string): string { // scope this on the user ID because people may login on many accounts // and they all need to be stored! @@ -205,6 +223,15 @@ export class SyncApi { RoomEvent.TimelineReset, ]); this.registerStateListeners(room); + // Register listeners again after the state reference changes + room.on(RoomEvent.CurrentStateUpdated, (targetRoom, previousCurrentState) => { + if (targetRoom !== room) { + return; + } + + this.deregisterStateListeners(previousCurrentState); + this.registerStateListeners(room); + }); return room; } @@ -237,17 +264,89 @@ export class SyncApi { RoomMemberEvent.Membership, ]); }); + + room.currentState.on(RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => { + this.onMarkerStateEvent(room, markerEvent, markerFoundOptions); + }); } /** - * @param {Room} room + * @param {RoomState} roomState The roomState to clear listeners from * @private */ - private deregisterStateListeners(room: Room): void { + private deregisterStateListeners(roomState: RoomState): void { // could do with a better way of achieving this. - room.currentState.removeAllListeners(RoomStateEvent.Events); - room.currentState.removeAllListeners(RoomStateEvent.Members); - room.currentState.removeAllListeners(RoomStateEvent.NewMember); + roomState.removeAllListeners(RoomStateEvent.Events); + roomState.removeAllListeners(RoomStateEvent.Members); + roomState.removeAllListeners(RoomStateEvent.NewMember); + roomState.removeAllListeners(RoomStateEvent.Marker); + } + + /** When we see the marker state change in the room, we know there is some + * new historical messages imported by MSC2716 `/batch_send` somewhere in + * the room and we need to throw away the timeline to make sure the + * historical messages are shown when we paginate `/messages` again. + * @param {Room} room The room where the marker event was sent + * @param {MatrixEvent} markerEvent The new marker event + * @param {ISetStateOptions} setStateOptions When `timelineWasEmpty` is set + * as `true`, the given marker event will be ignored + */ + private onMarkerStateEvent( + room: Room, + markerEvent: MatrixEvent, + { timelineWasEmpty }: IMarkerFoundOptions = {}, + ): void { + // We don't need to refresh the timeline if it was empty before the + // marker arrived. This could be happen in a variety of cases: + // 1. From the initial sync + // 2. If it's from the first state we're seeing after joining the room + // 3. Or whether it's coming from `syncFromCache` + if (timelineWasEmpty) { + logger.debug( + `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` + + `because the timeline was empty before the marker arrived which means there is nothing to refresh.`, + ); + return; + } + + const isValidMsc2716Event = + // Check whether the room version directly supports MSC2716, in + // which case, "marker" events are already auth'ed by + // power_levels + MSC2716_ROOM_VERSIONS.includes(room.getVersion()) || + // MSC2716 is also supported in all existing room versions but + // special meaning should only be given to "insertion", "batch", + // and "marker" events when they come from the room creator + markerEvent.getSender() === room.getCreator(); + + // It would be nice if we could also specifically tell whether the + // historical messages actually affected the locally cached client + // timeline or not. The problem is we can't see the prev_events of + // the base insertion event that the marker was pointing to because + // prev_events aren't available in the client API's. In most cases, + // the history won't be in people's locally cached timelines in the + // client, so we don't need to bother everyone about refreshing + // their timeline. This works for a v1 though and there are use + // cases like initially bootstrapping your bridged room where people + // are likely to encounter the historical messages affecting their + // current timeline (think someone signing up for Beeper and + // importing their Whatsapp history). + if (isValidMsc2716Event) { + // Saw new marker event, let's let the clients know they should + // refresh the timeline. + logger.debug( + `MarkerState: Timeline needs to be refreshed because ` + + `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`, + ); + room.setTimelineNeedsRefresh(true); + room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room); + } else { + logger.debug( + `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` + + `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` + + `by the room creator.`, + ); + } } /** @@ -1248,7 +1347,6 @@ export class SyncApi { } if (limited) { - this.deregisterStateListeners(room); room.resetLiveTimeline( joinObj.timeline.prev_batch, this.opts.canResetEntireTimeline(room.roomId) ? @@ -1259,8 +1357,6 @@ export class SyncApi { // reason to stop incrementally tracking notifications and // reset the timeline. client.resetNotifTimelineSet(); - - this.registerStateListeners(room); } } @@ -1584,7 +1680,9 @@ export class SyncApi { for (const ev of stateEventList) { this.client.getPushActionsForEvent(ev); } - liveTimeline.initialiseState(stateEventList); + liveTimeline.initialiseState(stateEventList, { + timelineWasEmpty, + }); } this.resolveInvites(room); @@ -1622,7 +1720,10 @@ export class SyncApi { // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], null, fromCache); + room.addLiveEvents(timelineEventList || [], { + fromCache, + timelineWasEmpty, + }); this.client.processBeaconEvents(room, timelineEventList); } From 871149912102b7fe65dc49df35be033f7cea5e62 Mon Sep 17 00:00:00 2001 From: Faye Duxovni Date: Thu, 2 Jun 2022 13:28:08 -0400 Subject: [PATCH 06/41] Don't bug the user while re-checking key backups after decryption failures (#2430) --- src/client.ts | 12 ------------ src/crypto/backup.ts | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/client.ts b/src/client.ts index e0f40a8a2ba..3cd18a0f0e6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3102,18 +3102,6 @@ export class MatrixClient extends TypedEventEmitter { - const privKey = await this.crypto.backupManager.getKey(); - return this.restoreKeyBackup( - privKey, targetRoomId, targetSessionId, backupInfo, opts, - ); - } - private async restoreKeyBackup( privKey: ArrayLike, targetRoomId: undefined, diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 94ec3c62445..2f03865824d 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -301,7 +301,7 @@ export class BackupManager { || now - this.sessionLastCheckAttemptedTime[targetSessionId] > KEY_BACKUP_CHECK_RATE_LIMIT ) { this.sessionLastCheckAttemptedTime[targetSessionId] = now; - await this.baseApis.restoreKeyBackupWithBackupManager(targetRoomId, targetSessionId, this.backupInfo, {}); + await this.baseApis.restoreKeyBackupWithCache(targetRoomId, targetSessionId, this.backupInfo, {}); } } From 012f6c56e6d45d22adfbe85ae07f7dc539b23625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 3 Jun 2022 06:06:48 +0200 Subject: [PATCH 07/41] Update MSC3786 implementation: Check the `state_key` (#2429) --- spec/unit/pushprocessor.spec.js | 1 + src/pushprocessor.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/spec/unit/pushprocessor.spec.js b/spec/unit/pushprocessor.spec.js index df7666d5cd3..0fab43d07fa 100644 --- a/spec/unit/pushprocessor.spec.js +++ b/spec/unit/pushprocessor.spec.js @@ -302,6 +302,7 @@ describe('NotificationService', function() { type: EventType.RoomServerAcl, room: testRoomId, user: "@alfred:localhost", + skey: "", event: true, content: {}, }); diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 448015203d6..950f395b7ed 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -83,6 +83,11 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ key: "type", pattern: EventType.RoomServerAcl, }, + { + kind: ConditionKind.EventMatch, + key: "state_key", + pattern: "", + }, ], actions: [], }, From 518e16e6d5455b97d3ada58747794a3c57a0c85c Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 3 Jun 2022 10:35:26 +0200 Subject: [PATCH 08/41] matrix-mock-request to 2.0.1 (#2416) * matrix-mock-request to 2.0.0 Signed-off-by: Kerry Archibald * track and destroy timeouts from test client Signed-off-by: Kerry Archibald * remove debug Signed-off-by: Kerry Archibald * fix bad property refernce caught by ts TestClient Signed-off-by: Kerry Archibald * Revert "fix bad property refernce caught by ts TestClient" This reverts commit 92c9f6cb1308fe1afdf0655babcb886acebf05ca. * update yarn lock Signed-off-by: Kerry Archibald * correct IUploadKeysRequest type * fix types in TestClient for typed matrix-mock-request Signed-off-by: Kerry Archibald * update to matrix-mock-request 2.0.1 Signed-off-by: Kerry Archibald --- package.json | 2 +- spec/TestClient.ts | 35 +-- spec/unit/crypto/secrets.spec.js | 3 +- spec/unit/crypto/verification/request.spec.js | 3 +- spec/unit/crypto/verification/sas.spec.js | 13 +- spec/unit/crypto/verification/util.js | 11 +- src/client.ts | 8 +- yarn.lock | 206 ++---------------- 8 files changed, 64 insertions(+), 217 deletions(-) diff --git a/package.json b/package.json index 118076e90a8..f1e57043a38 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "jest-localstorage-mock": "^2.4.6", "jest-sonar-reporter": "^2.0.0", "jsdoc": "^3.6.6", - "matrix-mock-request": "^1.2.3", + "matrix-mock-request": "^2.0.1", "rimraf": "^3.0.2", "terser": "^5.5.1", "tsify": "^5.0.2", diff --git a/spec/TestClient.ts b/spec/TestClient.ts index 42eb5fa28da..dc3d556b0e0 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -31,6 +31,8 @@ import { MockStorageApi } from "./MockStorageApi"; import { encodeUri } from "../src/utils"; import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; import { IKeyBackupSession } from "../src/crypto/keybackup"; +import { IHttpOpts } from "../src/http-api"; +import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client'; /** * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient @@ -61,7 +63,7 @@ export class TestClient { accessToken: accessToken, deviceId: deviceId, sessionStore: sessionStore, - request: this.httpBackend.requestFn, + request: this.httpBackend.requestFn as IHttpOpts["request"], ...options, }; if (!fullOptions.cryptoStore) { @@ -109,26 +111,27 @@ export class TestClient { * stop the client * @return {Promise} Resolves once the mock http backend has finished all pending flushes */ - public stop(): Promise { + public async stop(): Promise { this.client.stopClient(); - return this.httpBackend.stop(); + await this.httpBackend.stop(); } /** * Set up expectations that the client will upload device keys. */ public expectDeviceKeyUpload() { - this.httpBackend.when("POST", "/keys/upload").respond(200, (path, content) => { - expect(content.one_time_keys).toBe(undefined); - expect(content.device_keys).toBeTruthy(); + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (_path, content) => { + expect(content.one_time_keys).toBe(undefined); + expect(content.device_keys).toBeTruthy(); - logger.log(this + ': received device keys'); - // we expect this to happen before any one-time keys are uploaded. - expect(Object.keys(this.oneTimeKeys).length).toEqual(0); + logger.log(this + ': received device keys'); + // we expect this to happen before any one-time keys are uploaded. + expect(Object.keys(this.oneTimeKeys).length).toEqual(0); - this.deviceKeys = content.device_keys; - return { one_time_key_counts: { signed_curve25519: 0 } }; - }); + this.deviceKeys = content.device_keys; + return { one_time_key_counts: { signed_curve25519: 0 } }; + }); } /** @@ -145,7 +148,7 @@ export class TestClient { } this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { + .respond(200, (_path, content: IUploadKeysRequest) => { expect(content.device_keys).toBe(undefined); expect(content.one_time_keys).toBe(undefined); return { one_time_key_counts: { @@ -154,7 +157,7 @@ export class TestClient { }); this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { + .respond(200, (_path, content: IUploadKeysRequest) => { expect(content.device_keys).toBe(undefined); expect(content.one_time_keys).toBeTruthy(); expect(content.one_time_keys).not.toEqual({}); @@ -181,8 +184,8 @@ export class TestClient { * @param {Object} response response to the query. */ public expectKeyQuery(response: IDownloadKeyResult) { - this.httpBackend.when('POST', '/keys/query').respond( - 200, (path, content) => { + this.httpBackend.when('POST', '/keys/query').respond( + 200, (_path, content) => { Object.keys(response.device_keys).forEach((userId) => { expect(content.device_keys[userId]).toEqual([]); }); diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.js index 2c3cdd3865c..6a1e41f4003 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.js @@ -223,7 +223,7 @@ describe("Secrets", function() { }); it("should request secrets from other clients", async function() { - const [osborne2, vax] = await makeTestClients( + const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "VAX" }, @@ -280,6 +280,7 @@ describe("Secrets", function() { expect(secret).toBe("bar"); osborne2.stop(); vax.stop(); + clearTestClientTimeouts(); }); describe("bootstrap", function() { diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.js index 1daac5cfdca..e530344e2eb 100644 --- a/spec/unit/crypto/verification/request.spec.js +++ b/spec/unit/crypto/verification/request.spec.js @@ -40,7 +40,7 @@ describe("verification request integration tests with crypto layer", function() }); it("should request and accept a verification", async function() { - const [alice, bob] = await makeTestClients( + const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -81,5 +81,6 @@ describe("verification request integration tests with crypto layer", function() alice.stop(); bob.stop(); + clearTestClientTimeouts(); }); }); diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js index a6806ef40f5..0a57e55a377 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.js @@ -75,9 +75,10 @@ describe("SAS verification", function() { let bobSasEvent; let aliceVerifier; let bobPromise; + let clearTestClientTimeouts; beforeEach(async () => { - [alice, bob] = await makeTestClients( + [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -178,6 +179,8 @@ describe("SAS verification", function() { alice.stop(), bob.stop(), ]); + + clearTestClientTimeouts(); }); it("should verify a key", async () => { @@ -334,7 +337,7 @@ describe("SAS verification", function() { }); it("should send a cancellation message on error", async function() { - const [alice, bob] = await makeTestClients( + const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -380,6 +383,7 @@ describe("SAS verification", function() { alice.stop(); bob.stop(); + clearTestClientTimeouts(); }); describe("verification in DM", function() { @@ -389,9 +393,10 @@ describe("SAS verification", function() { let bobSasEvent; let aliceVerifier; let bobPromise; + let clearTestClientTimeouts; beforeEach(async function() { - [alice, bob] = await makeTestClients( + [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -491,6 +496,8 @@ describe("SAS verification", function() { alice.stop(), bob.stop(), ]); + + clearTestClientTimeouts(); }); it("should verify a key", async function() { diff --git a/spec/unit/crypto/verification/util.js b/spec/unit/crypto/verification/util.js index a6532dff132..572a4b270d2 100644 --- a/spec/unit/crypto/verification/util.js +++ b/spec/unit/crypto/verification/util.js @@ -23,6 +23,7 @@ import { logger } from '../../../../src/logger'; export async function makeTestClients(userInfos, options) { const clients = []; + const timeouts = []; const clientMap = {}; const sendToDevice = function(type, map) { // logger.log(this.getUserId(), "sends", type, map); @@ -66,7 +67,7 @@ export async function makeTestClients(userInfos, options) { }, })); - setImmediate(() => { + const timeout = setTimeout(() => { for (const tc of clients) { if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this logger.log("sending remote echo!!"); @@ -77,6 +78,8 @@ export async function makeTestClients(userInfos, options) { } }); + timeouts.push(timeout); + return Promise.resolve({ event_id: eventId }); }; @@ -103,7 +106,11 @@ export async function makeTestClients(userInfos, options) { await Promise.all(clients.map((testClient) => testClient.client.initCrypto())); - return clients; + const destroy = () => { + timeouts.forEach((t) => clearTimeout(t)); + }; + + return [clients, destroy]; } export function setupWebcrypto() { diff --git a/src/client.ts b/src/client.ts index 3cd18a0f0e6..92d9898167e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -590,13 +590,9 @@ export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse { intl_fmt: string; } -interface IUploadKeysRequest { +export interface IUploadKeysRequest { device_keys?: Required; - one_time_keys?: { - [userId: string]: { - [deviceId: string]: number; - }; - }; + one_time_keys?: Record; "org.matrix.msc2732.fallback_keys"?: Record; } diff --git a/yarn.lock b/yarn.lock index 1251583cbd0..a8ffa18fd36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1306,7 +1306,6 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz": version "3.2.8" - uid "8d53636d045e1776e2a2ec6613e57330dd9ce856" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": @@ -2129,7 +2128,7 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -bluebird@^3.5.0, bluebird@^3.7.2: +bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -2808,7 +2807,7 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== -define-properties@^1.1.3, define-properties@^1.1.4, define-properties@~1.1.2: +define-properties@^1.1.3, define-properties@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== @@ -3014,20 +3013,6 @@ es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19 string.prototype.trimstart "^1.0.5" unbox-primitive "^1.0.2" -es-get-iterator@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.2.tgz#9234c54aba713486d7ebde0220864af5e2b283f7" - integrity sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.0" - has-symbols "^1.0.1" - is-arguments "^1.1.0" - is-map "^2.0.2" - is-set "^2.0.2" - is-string "^1.0.5" - isarray "^2.0.5" - es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" @@ -3327,19 +3312,6 @@ exorcist@^1.0.1: mkdirp "~0.5.1" mold-source-map "~0.4.0" -expect@^1.20.2: - version "1.20.2" - resolved "https://registry.yarnpkg.com/expect/-/expect-1.20.2.tgz#d458fe4c56004036bae3232416a3f6361f04f965" - integrity sha512-vUOB6rNLhhRgchrNzJZH72FXDgiHmmEqX07Nlb1363HyZm/GFzkNMq0X0eIygMtdc4f2okltziddtVM4D5q0Jw== - dependencies: - define-properties "~1.1.2" - has "^1.0.1" - is-equal "^1.5.1" - is-regex "^1.0.3" - object-inspect "^1.1.0" - object-keys "^1.0.9" - tmatch "^2.0.1" - expect@^28.1.0: version "28.1.0" resolved "https://registry.yarnpkg.com/expect/-/expect-28.1.0.tgz#10e8da64c0850eb8c39a480199f14537f46e8360" @@ -3720,7 +3692,7 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" -has@^1.0.0, has@^1.0.1, has@^1.0.3: +has@^1.0.0, has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== @@ -3873,7 +3845,7 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" -is-arguments@^1.0.4, is-arguments@^1.1.0: +is-arguments@^1.0.4: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== @@ -3886,21 +3858,7 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== -is-arrow-function@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-arrow-function/-/is-arrow-function-2.0.3.tgz#29be2c2d8d9450852b8bbafb635ba7b8d8e87ec2" - integrity sha512-iDStzcT1FJMzx+TjCOK//uDugSe/Mif/8a+T0htydQ3qkJGvSweTZpVYz4hpJH0baloSPiAFQdA8WslAgJphvQ== - dependencies: - is-callable "^1.0.4" - -is-async-function@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.0.0.tgz#8e4418efd3e5d3a6ebb0164c05ef5afb69aa9646" - integrity sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA== - dependencies: - has-tostringtag "^1.0.0" - -is-bigint@^1.0.1, is-bigint@^1.0.4: +is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== @@ -3914,7 +3872,7 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-boolean-object@^1.1.0, is-boolean-object@^1.1.2: +is-boolean-object@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== @@ -3927,7 +3885,7 @@ is-buffer@^1.1.0, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.0.4, is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -3939,40 +3897,13 @@ is-core-module@^2.8.1: dependencies: has "^1.0.3" -is-date-object@^1.0.1, is-date-object@^1.0.5: +is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== dependencies: has-tostringtag "^1.0.0" -is-equal@^1.5.1: - version "1.6.4" - resolved "https://registry.yarnpkg.com/is-equal/-/is-equal-1.6.4.tgz#9a51b9ff565637ca2452356e293e9c98a1490ea1" - integrity sha512-NiPOTBb5ahmIOYkJ7mVTvvB1bydnTzixvfO+59AjJKBpyjPBIULL3EHGxySyZijlVpewveJyhiLQThcivkkAtw== - dependencies: - es-get-iterator "^1.1.2" - functions-have-names "^1.2.2" - has "^1.0.3" - has-bigints "^1.0.1" - has-symbols "^1.0.2" - is-arrow-function "^2.0.3" - is-bigint "^1.0.4" - is-boolean-object "^1.1.2" - is-callable "^1.2.4" - is-date-object "^1.0.5" - is-generator-function "^1.0.10" - is-number-object "^1.0.6" - is-regex "^1.1.4" - is-string "^1.0.7" - is-symbol "^1.0.4" - isarray "^2.0.5" - object-inspect "^1.12.0" - object.entries "^1.1.5" - object.getprototypeof "^1.0.3" - which-boxed-primitive "^1.0.2" - which-collection "^1.0.1" - is-expression@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-3.0.0.tgz#39acaa6be7fd1f3471dc42c7416e61c24317ac9f" @@ -3986,13 +3917,6 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== -is-finalizationregistry@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz#c8749b65f17c133313e661b1289b95ad3dbd62e6" - integrity sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw== - dependencies: - call-bind "^1.0.2" - is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" @@ -4003,7 +3927,7 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-generator-function@^1.0.10, is-generator-function@^1.0.7: +is-generator-function@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== @@ -4017,17 +3941,12 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" -is-map@^2.0.1, is-map@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" - integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== - is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== -is-number-object@^1.0.4, is-number-object@^1.0.6: +is-number-object@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== @@ -4064,11 +3983,6 @@ is-regex@^1.0.3, is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-set@^2.0.1, is-set@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" - integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g== - is-shared-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" @@ -4093,7 +4007,7 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" -is-symbol@^1.0.2, is-symbol@^1.0.3, is-symbol@^1.0.4: +is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== @@ -4121,11 +4035,6 @@ is-utf8@^0.2.0: resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" integrity sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q== -is-weakmap@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" - integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA== - is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -4133,19 +4042,6 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" -is-weakset@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.2.tgz#4569d67a747a1ce5a994dfd4ef6dcea76e7c0a1d" - integrity sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.1" - -isarray@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" - integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== - isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -4937,13 +4833,12 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -matrix-mock-request@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-1.2.3.tgz#56b15d86e2601a9b48a854844396d18caab649c8" - integrity sha512-Tr7LDHweTW8Ql4C8XhGQFGMzuh+HmPjOcQqrHH1qfSesq0cwdPWanvdnllNjeHoAMcZ43HpMFMzFZfNW1/6HYg== +matrix-mock-request@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.0.1.tgz#1cf7b516f8525de8373f1d9985a4a447db80bb96" + integrity sha512-NqCSDRBUTXKY7TS5H6Fqu6oxSsWKGkyh3LTXa/T6mSGABi2zMkeqGa2r2H3rnH6waJRt5N7xn+u7vEmSpg0oBQ== dependencies: - bluebird "^3.5.0" - expect "^1.20.2" + expect "^28.1.0" md5.js@^1.3.4: version "1.3.5" @@ -5170,12 +5065,12 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= -object-inspect@^1.1.0, object-inspect@^1.12.0, object-inspect@^1.9.0: +object-inspect@^1.12.0, object-inspect@^1.9.0: version "1.12.2" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== -object-keys@^1.0.9, object-keys@^1.1.1: +object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== @@ -5190,25 +5085,6 @@ object.assign@^4.1.0, object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" -object.entries@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861" - integrity sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.1" - -object.getprototypeof@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/object.getprototypeof/-/object.getprototypeof-1.0.3.tgz#92e0c2320ffd3990f3378c9c3489929af31a190f" - integrity sha512-EP3J0rXZA4OuvSl98wYa0hY5zHUJo2kGrp2eYDro0yCe3yrKm7xtXDgbpT+YPK2RzdtdvJtm0IfaAyXeehQR0w== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.1" - reflect.getprototypeof "^1.0.2" - object.values@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" @@ -5803,17 +5679,6 @@ recast@^0.17.3: private "^0.1.8" source-map "~0.6.1" -reflect.getprototypeof@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.2.tgz#dd231808828913fd2198e151eb3e213d9dddf708" - integrity sha512-C1+ANgX50UkWlntmOJ8SD1VTuk28+7X1ackBdfXzLQG5+bmriEMHvBaor9YlotCfBHo277q/YWd/JKEOzr5Dxg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.1" - get-intrinsic "^1.1.1" - which-builtin-type "^1.1.1" - regenerate-unicode-properties@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" @@ -6409,11 +6274,6 @@ timers-ext@^0.1.7: es5-ext "~0.10.46" next-tick "1" -tmatch@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/tmatch/-/tmatch-2.0.1.tgz#0c56246f33f30da1b8d3d72895abaf16660f38cf" - integrity sha1-DFYkbzPzDaG409colauvFmYPOM8= - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -6840,35 +6700,7 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" -which-builtin-type@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.2.tgz#254a34f6cd2a546e04d51d9a4ac2c65e9ed31bf4" - integrity sha512-2/+MF0XNPySHrIPlIAUB1dmQuWOPfQDR+TvwZs2tayroIA61MvZDJtkvwjv2iDg7h668jocdWsPOQwwAz5QUSg== - dependencies: - function.prototype.name "^1.1.5" - has-tostringtag "^1.0.0" - is-async-function "^2.0.0" - is-date-object "^1.0.5" - is-finalizationregistry "^1.0.2" - is-generator-function "^1.0.10" - is-regex "^1.1.4" - is-weakref "^1.0.2" - isarray "^2.0.5" - which-boxed-primitive "^1.0.2" - which-collection "^1.0.1" - which-typed-array "^1.1.7" - -which-collection@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906" - integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A== - dependencies: - is-map "^2.0.1" - is-set "^2.0.1" - is-weakmap "^2.0.1" - is-weakset "^2.0.1" - -which-typed-array@^1.1.2, which-typed-array@^1.1.7: +which-typed-array@^1.1.2: version "1.1.8" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f" integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw== From e35ede0370fefc3c6470409d9f65281025777105 Mon Sep 17 00:00:00 2001 From: Faye Duxovni Date: Fri, 3 Jun 2022 08:58:14 -0400 Subject: [PATCH 09/41] The request callback provided by bootstrapCrossSigning is async (#2431) --- src/crypto/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 474bfd3f636..4add71f978e 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -121,7 +121,7 @@ interface IInitOpts { export interface IBootstrapCrossSigningOpts { setupNewCrossSigning?: boolean; - authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise; } /* eslint-disable camelcase */ From 76797704eaa09774184a026b19280718b8014241 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 6 Jun 2022 11:37:49 +0100 Subject: [PATCH 10/41] Move pr_details and sonarqube to released composite actions (#2425) * Move pr_details and sonarqube to released composite actions * Modify correct file * Bring back a reusable workflow for element-web stack sonarqube runs * Move sonarcloud.yml to the right repo * Update to matrix-org/sonarcloud-workflow-action@v2.1 --- .github/workflows/pr_details.yml | 49 ----------------- .github/workflows/sonarcloud.yml | 92 +++++--------------------------- .github/workflows/sonarqube.yml | 22 -------- 3 files changed, 12 insertions(+), 151 deletions(-) delete mode 100644 .github/workflows/pr_details.yml diff --git a/.github/workflows/pr_details.yml b/.github/workflows/pr_details.yml deleted file mode 100644 index adb5ed6233f..00000000000 --- a/.github/workflows/pr_details.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Find details about the PR associated with this ref -name: PR Details -on: - workflow_call: - inputs: - owner: - type: string - required: true - description: The github username of the owner of the head branch - branch: - type: string - required: true - description: The name of the head branch - outputs: - pr_id: - description: The ID of the PR found - value: ${{ fromJSON(jobs.prdetails.outputs.result).number }} - head_branch: - description: The head branch of the PR found - value: ${{ fromJSON(jobs.prdetails.outputs.result).head.ref }} - base_branch: - description: The base branch of the PR found - value: ${{ fromJSON(jobs.prdetails.outputs.result).base.ref }} - data: - description: The JSON data of the pull request API object - value: ${{ jobs.prdetails.outputs.result }}) - -jobs: - prdetails: - name: Find PR Details - runs-on: ubuntu-latest - steps: - - name: "🔍 Read PR details" - id: details - uses: actions/github-script@v5 - with: - # We need to find the PR number that corresponds to the branch, which we do by searching the GH API - # The workflow_run event includes a list of pull requests, but it doesn't get populated for - # forked PRs: https://docs.github.com/en/rest/reference/checks#create-a-check-run - script: | - const [owner, repo] = "${{ github.repository }}".split("/"); - const response = await github.rest.pulls.list({ - head: "${{ inputs.owner }}:${{ inputs.branch }}", - owner, - repo, - }); - return response.data[0]; - outputs: - result: ${{ steps.details.outputs.result }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index d6419c4eee0..ecf6fb9381f 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -2,91 +2,23 @@ name: SonarCloud on: workflow_call: - inputs: - repo: - type: string - required: true - description: The full name of the repo in org/repo format - head_branch: - type: string - required: true - description: The name of the head branch - # We cannot use ${{ github.sha }} here as for pull requests it'll be a simulated merge commit instead - revision: - type: string - required: true - description: The git revision with which this sonar run should be associated - - # Coverage specific parameters, assumes coverage reports live in a /coverage/ directory - coverage_workflow_name: - type: string - required: false - description: The name of the workflow which uploaded the `coverage` artifact, if any - coverage_run_id: - type: string - required: false - description: The run_id of the workflow which upload the coverage relevant to this run - - # PR specific parameters - pr_id: - type: string - required: false - description: The ID number of the PR if this workflow is being triggered due to one - base_branch: - type: string - required: false - description: The base branch of the PR if this workflow is being triggered due to one secrets: SONAR_TOKEN: required: true jobs: - analysis: - name: Analysis + sonarqube: runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' steps: - - name: "🧮 Checkout code" - uses: actions/checkout@v3 - with: - repository: ${{ inputs.repo }} - ref: ${{ inputs.head_branch }} # checkout commit that triggered this workflow - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - # Fetch base branch from the upstream repo so that Sonar can identify new code in PR builds - - name: "📕 Fetch upstream base branch" - # workflow_call retains the github context of the caller, so `repository` will be upstream always due - # to it running on `workflow_run` which is called from the context of the target repo and not the fork. - if: inputs.base_branch - run: | - git remote add upstream https://github.com/${{ github.repository }} - git rev-parse HEAD - git fetch upstream ${{ inputs.base_branch }}:${{ inputs.base_branch }} - git status - git rev-parse HEAD - - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action - # (https://github.com/actions/download-artifact/issues/60) so instead we get this alternative: - - name: "📥 Download Coverage Report" - uses: dawidd6/action-download-artifact@v2 - if: inputs.coverage_workflow_name - with: - workflow: ${{ inputs.coverage_workflow_name }} - run_id: ${{ inputs.coverage_run_id }} - name: coverage - path: coverage - - - name: "🔍 Read package.json version" - id: version - uses: martinbeentjes/npm-get-version-action@main - - name: "🩻 SonarCloud Scan" - uses: SonarSource/sonarcloud-github-action@master + uses: matrix-org/sonarcloud-workflow-action@v2.1 with: - args: > - -Dsonar.projectVersion=${{ steps.version.outputs.current-version }} - -Dsonar.scm.revision=${{ inputs.revision }} - -Dsonar.pullrequest.key=${{ inputs.pr_id }} - -Dsonar.pullrequest.branch=${{ inputs.pr_id && inputs.head_branch }} - -Dsonar.pullrequest.base=${{ inputs.pr_id && inputs.base_branch }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + repository: ${{ github.event.workflow_run.head_repository.full_name }} + is_pr: ${{ github.event.workflow_run.event == 'pull_request' }} + version_cmd: 'cat package.json | jq -r .version' + branch: ${{ github.event.workflow_run.head_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.SONAR_TOKEN }} + coverage_run_id: ${{ github.event.workflow_run.id }} + coverage_workflow_name: tests.yml + coverage_extract_path: coverage diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 11660e68ba4..a5360c64fbb 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -8,30 +8,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: true jobs: - prdetails: - name: ℹ️ PR Details - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' - uses: matrix-org/matrix-js-sdk/.github/workflows/pr_details.yml@develop - with: - owner: ${{ github.event.workflow_run.head_repository.owner.login }} - branch: ${{ github.event.workflow_run.head_branch }} - sonarqube: name: 🩻 SonarQube - needs: prdetails - # Only wait for prdetails if it isn't skipped - if: | - always() && - (needs.prdetails.result == 'success' || needs.prdetails.result == 'skipped') && - github.event.workflow_run.conclusion == 'success' uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop - with: - repo: ${{ github.event.workflow_run.head_repository.full_name }} - pr_id: ${{ needs.prdetails.outputs.pr_id }} - head_branch: ${{ needs.prdetails.outputs.head_branch || github.event.workflow_run.head_branch }} - base_branch: ${{ needs.prdetails.outputs.base_branch }} - revision: ${{ github.event.workflow_run.head_sha }} - coverage_workflow_name: tests.yml - coverage_run_id: ${{ github.event.workflow_run.id }} secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From d73126ecb266e2d4579cf20084267b5ccfc451cb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 6 Jun 2022 14:13:16 +0100 Subject: [PATCH 11/41] Document how to inhibit code coverage requirement (#2436) on specific sections --- CONTRIBUTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e495d19ce72..158c916a3e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,6 +129,16 @@ When writing unit tests, please aim for a high level of test coverage for new code - 80% or greater. If you cannot achieve that, please document why it's not possible in your PR. +Some sections of code are not sensible to add coverage for, such as those +which explicitly inhibit noisy logging for tests. Which can be hidden using +an istanbul magic comment as [documented here][1]. See example: +```javascript +/* istanbul ignore if */ +if (process.env.NODE_ENV !== "test") { + logger.error("Log line that is noisy enough in tests to want to skip"); +} +``` + Tests validate that your change works as intended and also document concisely what is being changed. Ideally, your new tests fail prior to your change, and succeed once it has been applied. You may @@ -258,3 +268,5 @@ When stacking pull requests, you may wish to do the following: 2. Branch from your base branch (branch1) to your work branch (branch2), push commits and open a pull request configuring the base to be branch1, saying in the description that it is based on your other PR. 3. Merge the first PR using a merge commit otherwise your stacked PR will need a rebase. Github will automatically adjust the base branch of your other PR to be develop. + +[1]: https://github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md From aa94d5d95c732b7a08287eafbd220424771a6486 Mon Sep 17 00:00:00 2001 From: Jonathan de Jong Date: Mon, 6 Jun 2022 16:09:32 +0200 Subject: [PATCH 12/41] Assume per-user deviceID uniqueness in encryptAndSendKeysToDevices (#2136) * Segment recorded device info by user ID when tracking key shares. Fixes #2135. * address review feedback * fix userIdDeviceInfo Co-authored-by: Denis Kasak Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/crypto/algorithms/megolm.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index b7bb1165a6e..8bf5a7c629a 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -592,7 +592,8 @@ class MegolmEncryption extends EncryptionAlgorithm { payload: IPayload, ): Promise { const contentMap: Record> = {}; - const deviceInfoByDeviceId = new Map(); + // Map from userId to a map of deviceId to deviceInfo + const deviceInfoByUserIdAndDeviceId = new Map>(); const promises: Promise[] = []; for (let i = 0; i < userDeviceMap.length; i++) { @@ -605,7 +606,18 @@ class MegolmEncryption extends EncryptionAlgorithm { const userId = val.userId; const deviceInfo = val.deviceInfo; const deviceId = deviceInfo.deviceId; - deviceInfoByDeviceId.set(deviceId, deviceInfo); + + // Assign to temp value to make type-checking happy + let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId); + + if (userIdDeviceInfo === undefined) { + userIdDeviceInfo = new Map(); + + deviceInfoByUserIdAndDeviceId.set(userId, userIdDeviceInfo); + } + + // We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId] + userIdDeviceInfo.set(deviceId, deviceInfo); if (!contentMap[userId]) { contentMap[userId] = {}; @@ -660,7 +672,7 @@ class MegolmEncryption extends EncryptionAlgorithm { session.markSharedWithDevice( userId, deviceId, - deviceInfoByDeviceId.get(deviceId).getIdentityKey(), + deviceInfoByUserIdAndDeviceId.get(userId).get(deviceId).getIdentityKey(), chainIndex, ); } From 07189f0637ae71ebce05dcd7107d6a78a0e0d33e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jun 2022 10:13:01 +0100 Subject: [PATCH 13/41] Add tests for sendEvent threadId handling (#2435) * Add tests for sendEvent threadId handling * Fix sendEvent threadId relation support not adding `is_falling_back` field --- spec/unit/matrix-client.spec.ts | 91 ++++++++++++++++++++++++++++++--- src/client.ts | 9 ++-- src/content-helpers.ts | 4 +- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index c685ea3a24c..fbe8c67d7e7 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -87,7 +87,7 @@ describe("MatrixClient", function() { // } // items are popped off when processed and block if no items left. ]; - let acceptKeepalives; + let acceptKeepalives: boolean; let pendingLookup = null; function httpReq(cb, method, path, qp, data, prefix) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) { @@ -127,7 +127,7 @@ describe("MatrixClient", function() { (next.error ? "BAD" : "GOOD") + " response", ); if (next.expectBody) { - expect(next.expectBody).toEqual(data); + expect(data).toEqual(next.expectBody); } if (next.expectQueryParams) { Object.keys(next.expectQueryParams).forEach(function(k) { @@ -777,7 +777,7 @@ describe("MatrixClient", function() { expectBody: content, }]; - await client.sendEvent(roomId, EventType.RoomMessage, content, txnId); + await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); }); it("overload with null threadId works", async () => { @@ -790,20 +790,99 @@ describe("MatrixClient", function() { expectBody: content, }]; - await client.sendEvent(roomId, null, EventType.RoomMessage, content, txnId); + await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); }); it("overload with threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); + const threadId = "$threadId:server"; httpLookups = [{ method: "PUT", path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, data: { event_id: eventId }, - expectBody: content, + expectBody: { + ...content, + "m.relates_to": { + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: threadId, + }, + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing with reply", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const content = { + body, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + }, + }; + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + "event_id": threadId, + "is_falling_back": false, + "rel_type": "m.thread", + }, + }, }]; - await client.sendEvent(roomId, "$threadId:server", EventType.RoomMessage, content, txnId); + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); }); }); diff --git a/src/client.ts b/src/client.ts index 92d9898167e..9a2a0e27d17 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3772,17 +3772,20 @@ export class MatrixClient extends TypedEventEmitter { return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - })?.getId(), + })?.getId() ?? threadId, }; } } @@ -4031,7 +4034,7 @@ export class MatrixClient extends TypedEventEmitter Date: Tue, 7 Jun 2022 11:16:53 +0100 Subject: [PATCH 14/41] Refactor Relations to not be per-EventTimelineSet (#2412) * Refactor Relations to not be per-EventTimelineSet * Fix comment and relations-container init * Revert timing tweaks * Fix relations order test * Add test and simplify thread relations handling * Fix order of initialising a room object * Fix test * Re-add thread handling for relations of unloaded threads * Ditch confusing experimental getter `MatrixEvent::isThreadRelation` * Fix room handling in RelationsContainer * Iterate PR * Tweak method naming to closer match spec --- spec/unit/event-timeline-set.spec.ts | 12 +- spec/unit/relations.spec.ts | 22 ++-- spec/unit/room.spec.ts | 4 +- src/client.ts | 9 -- src/models/event-timeline-set.ts | 184 +++------------------------ src/models/event.ts | 7 - src/models/relations-container.ts | 155 ++++++++++++++++++++++ src/models/relations.ts | 14 +- src/models/room.ts | 33 +---- src/models/thread.ts | 15 +-- src/sync.ts | 2 - 11 files changed, 214 insertions(+), 243 deletions(-) create mode 100644 src/models/relations-container.ts diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index eaa723940b9..053a78e46c5 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -40,8 +40,8 @@ describe('EventTimelineSet', () => { const itShouldReturnTheRelatedEvents = () => { it('should return the related events', () => { - eventTimelineSet.aggregateRelations(messageEvent); - const relations = eventTimelineSet.getRelationsForEvent( + eventTimelineSet.relations.aggregateChildEvent(messageEvent); + const relations = eventTimelineSet.relations.getChildEventsForEvent( messageEvent.getId(), "m.in_reply_to", EventType.RoomMessage, @@ -55,9 +55,7 @@ describe('EventTimelineSet', () => { beforeEach(() => { client = utils.mock(MatrixClient, 'MatrixClient'); room = new Room(roomId, client, userA); - eventTimelineSet = new EventTimelineSet(room, { - unstableClientRelationAggregation: true, - }); + eventTimelineSet = new EventTimelineSet(room); eventTimeline = new EventTimeline(eventTimelineSet); messageEvent = utils.mkMessage({ room: roomId, @@ -189,8 +187,8 @@ describe('EventTimelineSet', () => { }); it('should not return the related events', () => { - eventTimelineSet.aggregateRelations(messageEvent); - const relations = eventTimelineSet.getRelationsForEvent( + eventTimelineSet.relations.aggregateChildEvent(messageEvent); + const relations = eventTimelineSet.relations.getChildEventsForEvent( messageEvent.getId(), "m.in_reply_to", EventType.RoomMessage, diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index c1e1dc8271e..091d95ea914 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -96,19 +96,14 @@ describe("Relations", function() { }, }); - // Stub the room - - const room = new Room("room123", null, null); - // Add the target event first, then the relation event { + const room = new Room("room123", null, null); const relationsCreated = new Promise(resolve => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); - const timelineSet = new EventTimelineSet(room, { - unstableClientRelationAggregation: true, - }); + const timelineSet = new EventTimelineSet(room); timelineSet.addLiveEvent(targetEvent); timelineSet.addLiveEvent(relationEvent); @@ -117,13 +112,12 @@ describe("Relations", function() { // Add the relation event first, then the target event { + const room = new Room("room123", null, null); const relationsCreated = new Promise(resolve => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); - const timelineSet = new EventTimelineSet(room, { - unstableClientRelationAggregation: true, - }); + const timelineSet = new EventTimelineSet(room); timelineSet.addLiveEvent(relationEvent); timelineSet.addLiveEvent(targetEvent); @@ -131,6 +125,14 @@ describe("Relations", function() { } }); + it("should re-use Relations between all timeline sets in a room", async () => { + const room = new Room("room123", null, null); + const timelineSet1 = new EventTimelineSet(room); + const timelineSet2 = new EventTimelineSet(room); + expect(room.relations).toBe(timelineSet1.relations); + expect(room.relations).toBe(timelineSet2.relations); + }); + it("should ignore m.replace for state events", async () => { const userId = "@bob:example.com"; const room = new Room("room123", null, userId); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 921feae1af4..3807795d1f3 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2334,7 +2334,7 @@ describe("Room", function() { const thread = threadRoot.getThread(); expect(thread.rootEvent).toBe(threadRoot); - const rootRelations = thread.timelineSet.getRelationsForEvent( + const rootRelations = thread.timelineSet.relations.getChildEventsForEvent( threadRoot.getId(), RelationType.Annotation, EventType.Reaction, @@ -2344,7 +2344,7 @@ describe("Room", function() { expect(rootRelations[0][1].size).toEqual(1); expect(rootRelations[0][1].has(rootReaction)).toBeTruthy(); - const responseRelations = thread.timelineSet.getRelationsForEvent( + const responseRelations = thread.timelineSet.relations.getChildEventsForEvent( threadResponse.getId(), RelationType.Annotation, EventType.Reaction, diff --git a/src/client.ts b/src/client.ts index 9a2a0e27d17..304655eac3b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -323,13 +323,6 @@ export interface ICreateClientOpts { */ sessionStore?: SessionStore; - /** - * Set to true to enable client-side aggregation of event relations - * via `EventTimelineSet#getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. - */ - unstableClientRelationAggregation?: boolean; - verificationMethods?: Array; /** @@ -903,7 +896,6 @@ export class MatrixClient extends TypedEventEmitter } = {}; - public unstableClientRelationAggregation = false; public identityServer: IIdentityServerProvider; public sessionStore: SessionStore; // XXX: Intended private, used in code. public http: MatrixHttpApi; // XXX: Intended private, used in code. @@ -1035,7 +1027,6 @@ export class MatrixClient extends TypedEventEmitter { + public readonly relations?: RelationsContainer; private readonly timelineSupport: boolean; - private unstableClientRelationAggregation: boolean; - private displayPendingEvents: boolean; + private readonly displayPendingEvents: boolean; private liveTimeline: EventTimeline; private timelines: EventTimeline[]; private _eventIdToTimeline: Record; private filter?: Filter; - private relations: Record>>; /** * Construct a set of EventTimeline objects, typically on behalf of a given @@ -121,17 +119,18 @@ export class EventTimelineSet extends TypedEventEmittereventId, relationType or eventType - * are not valid. - * - * @returns {?Relations} - * A container for relation events or undefined if there are no relation events for - * the relationType. - */ - public getRelationsForEvent( - eventId: string, - relationType: RelationType | string, - eventType: EventType | string, - ): Relations | undefined { - if (!this.unstableClientRelationAggregation) { - throw new Error("Client-side relation aggregation is disabled"); - } - - if (!eventId || !relationType || !eventType) { - throw new Error("Invalid arguments for `getRelationsForEvent`"); - } - - // debuglog("Getting relations for: ", eventId, relationType, eventType); - - const relationsForEvent = this.relations[eventId] || {}; - const relationsWithRelType = relationsForEvent[relationType] || {}; - return relationsWithRelType[eventType]; - } - - public getAllRelationsEventForEvent(eventId: string): MatrixEvent[] { - const relationsForEvent = this.relations?.[eventId] || {}; - const events = []; - for (const relationsRecord of Object.values(relationsForEvent)) { - for (const relations of Object.values(relationsRecord)) { - events.push(...relations.getRelations()); - } - } - return events; - } - - /** - * Set an event as the target event if any Relations exist for it already - * - * @param {MatrixEvent} event - * The event to check as relation target. - */ - public setRelationsTarget(event: MatrixEvent): void { - if (!this.unstableClientRelationAggregation) { - return; - } - - const relationsForEvent = this.relations[event.getId()]; - if (!relationsForEvent) { - return; - } - - for (const relationsWithRelType of Object.values(relationsForEvent)) { - for (const relationsWithEventType of Object.values(relationsWithRelType)) { - relationsWithEventType.setTargetEvent(event); - } - } - } - - /** - * Add relation events to the relevant relation collection. - * - * @param {MatrixEvent} event - * The new relation event to be aggregated. - */ - public aggregateRelations(event: MatrixEvent): void { - if (!this.unstableClientRelationAggregation) { - return; - } - - if (event.isRedacted() || event.status === EventStatus.CANCELLED) { - return; - } - - const onEventDecrypted = (event: MatrixEvent) => { - if (event.isDecryptionFailure()) { - // This could for example happen if the encryption keys are not yet available. - // The event may still be decrypted later. Register the listener again. - event.once(MatrixEventEvent.Decrypted, onEventDecrypted); - return; - } - - this.aggregateRelations(event); - }; - - // If the event is currently encrypted, wait until it has been decrypted. - if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - event.once(MatrixEventEvent.Decrypted, onEventDecrypted); - return; - } - - const relation = event.getRelation(); - if (!relation) { - return; - } - - const relatesToEventId = relation.event_id; - const relationType = relation.rel_type; - const eventType = event.getType(); - - // debuglog("Aggregating relation: ", event.getId(), eventType, relation); - - let relationsForEvent: Record>> = this.relations[relatesToEventId]; - if (!relationsForEvent) { - relationsForEvent = this.relations[relatesToEventId] = {}; - } - let relationsWithRelType = relationsForEvent[relationType]; - if (!relationsWithRelType) { - relationsWithRelType = relationsForEvent[relationType] = {}; - } - let relationsWithEventType = relationsWithRelType[eventType]; - - if (!relationsWithEventType) { - relationsWithEventType = relationsWithRelType[eventType] = new Relations( - relationType, - eventType, - this.room, - ); - const relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); - if (relatesToEvent) { - relationsWithEventType.setTargetEvent(relatesToEvent); - } - } - - relationsWithEventType.addEvent(event); - } } /** diff --git a/src/models/event.ts b/src/models/event.ts index 7e4de8e2251..15d0649c48d 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -514,13 +514,6 @@ export class MatrixEvent extends TypedEventEmittereventId, relationType or eventType + * are not valid. + * + * @returns {?Relations} + * A container for relation events or undefined if there are no relation events for + * the relationType. + */ + public getChildEventsForEvent( + eventId: string, + relationType: RelationType | string, + eventType: EventType | string, + ): Relations | undefined { + return this.relations[eventId]?.[relationType]?.[eventType]; + } + + public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] { + const relationsForEvent = this.relations[parentEventId] ?? {}; + const events: MatrixEvent[] = []; + for (const relationsRecord of Object.values(relationsForEvent)) { + for (const relations of Object.values(relationsRecord)) { + events.push(...relations.getRelations()); + } + } + return events; + } + + /** + * Set an event as the target event if any Relations exist for it already. + * Child events can point to other child events as their parent, so this method may be + * called for events which are also logically child events. + * + * @param {MatrixEvent} event The event to check as relation target. + */ + public aggregateParentEvent(event: MatrixEvent): void { + const relationsForEvent = this.relations[event.getId()]; + if (!relationsForEvent) return; + + for (const relationsWithRelType of Object.values(relationsForEvent)) { + for (const relationsWithEventType of Object.values(relationsWithRelType)) { + relationsWithEventType.setTargetEvent(event); + } + } + } + + /** + * Add relation events to the relevant relation collection. + * + * @param {MatrixEvent} event The new child event to be aggregated. + * @param {EventTimelineSet} timelineSet The event timeline set within which to search for the related event if any. + */ + public aggregateChildEvent(event: MatrixEvent, timelineSet?: EventTimelineSet): void { + if (event.isRedacted() || event.status === EventStatus.CANCELLED) { + return; + } + + const relation = event.getRelation(); + if (!relation) return; + + const onEventDecrypted = () => { + if (event.isDecryptionFailure()) { + // This could for example happen if the encryption keys are not yet available. + // The event may still be decrypted later. Register the listener again. + event.once(MatrixEventEvent.Decrypted, onEventDecrypted); + return; + } + + this.aggregateChildEvent(event, timelineSet); + }; + + // If the event is currently encrypted, wait until it has been decrypted. + if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + event.once(MatrixEventEvent.Decrypted, onEventDecrypted); + return; + } + + const { event_id: relatesToEventId, rel_type: relationType } = relation; + const eventType = event.getType(); + + let relationsForEvent = this.relations[relatesToEventId]; + if (!relationsForEvent) { + relationsForEvent = this.relations[relatesToEventId] = {}; + } + + let relationsWithRelType = relationsForEvent[relationType]; + if (!relationsWithRelType) { + relationsWithRelType = relationsForEvent[relationType] = {}; + } + + let relationsWithEventType = relationsWithRelType[eventType]; + if (!relationsWithEventType) { + relationsWithEventType = relationsWithRelType[eventType] = new Relations( + relationType, + eventType, + this.client, + ); + + const room = this.room ?? timelineSet?.room; + const relatesToEvent = timelineSet?.findEventById(relatesToEventId) + ?? room?.findEventById(relatesToEventId) + ?? room?.getPendingEvent(relatesToEventId); + if (relatesToEvent) { + relationsWithEventType.setTargetEvent(relatesToEvent); + } + } + + relationsWithEventType.addEvent(event); + } +} diff --git a/src/models/relations.ts b/src/models/relations.ts index b3d0d235172..21d32390797 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -15,10 +15,11 @@ limitations under the License. */ import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from './event'; -import { Room } from './room'; import { logger } from '../logger'; import { RelationType } from "../@types/event"; import { TypedEventEmitter } from "./typed-event-emitter"; +import { MatrixClient } from "../client"; +import { Room } from "./room"; export enum RelationsEvent { Add = "Relations.add", @@ -48,6 +49,7 @@ export class Relations extends TypedEventEmitter][] = []; private targetEvent: MatrixEvent = null; private creationEmitted = false; + private readonly client: MatrixClient; /** * @param {RelationType} relationType @@ -55,16 +57,16 @@ export class Relations extends TypedEventEmitter * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). */ public currentState: RoomState; + public readonly relations = new RelationsContainer(this.client, this); /** * @experimental @@ -338,10 +339,6 @@ export class Room extends TypedEventEmitter * "chronological". * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved * timeline support. - * @param {boolean} [opts.unstableClientRelationAggregation = false] - * Optional. Set to true to enable client-side aggregation of event relations - * via `EventTimelineSet#getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. */ constructor( public readonly roomId: string, @@ -1737,7 +1734,7 @@ export class Room extends TypedEventEmitter } // A thread relation is always only shown in a thread - if (event.isThreadRelation) { + if (event.isRelation(THREAD_RELATION_TYPE.name)) { return { shouldLiveInRoom: false, shouldLiveInThread: true, @@ -1816,8 +1813,7 @@ export class Room extends TypedEventEmitter toStartOfTimeline: boolean, ): Thread { if (rootEvent) { - const tl = this.getTimelineForEvent(rootEvent.getId()); - const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId()); + const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId()); if (relatedEvents?.length) { // Include all relations of the root event, given it'll be visible in both timelines, // except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced` @@ -2102,24 +2098,7 @@ export class Room extends TypedEventEmitter * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. */ private aggregateNonLiveRelation(event: MatrixEvent): void { - const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); - const thread = this.getThread(threadId); - thread?.timelineSet.aggregateRelations(event); - - if (shouldLiveInRoom) { - // TODO: We should consider whether this means it would be a better - // design to lift the relations handling up to the room instead. - for (let i = 0; i < this.timelineSets.length; i++) { - const timelineSet = this.timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.aggregateRelations(event); - } - } else { - timelineSet.aggregateRelations(event); - } - } - } + this.relations.aggregateChildEvent(event); } public getEventForTxnId(txnId: string): MatrixEvent { @@ -2405,7 +2384,7 @@ export class Room extends TypedEventEmitter private findThreadRoots(events: MatrixEvent[]): Set { const threadRoots = new Set(); for (const event of events) { - if (event.isThreadRelation) { + if (event.isRelation(THREAD_RELATION_TYPE.name)) { threadRoots.add(event.relationEventId); } } diff --git a/src/models/thread.ts b/src/models/thread.ts index 4fb8bed4212..28bb9933d92 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -88,7 +88,6 @@ export class Thread extends TypedEventEmitter { this.room = opts.room; this.client = opts.client; this.timelineSet = new EventTimelineSet(this.room, { - unstableClientRelationAggregation: true, timelineSupport: true, pendingEvents: true, }); @@ -166,6 +165,7 @@ export class Thread extends TypedEventEmitter { private onEcho = (event: MatrixEvent) => { if (event.threadRootId !== this.id) return; // ignore echoes for other timelines if (this.lastEvent === event) return; + if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; // There is a risk that the `localTimestamp` approximation will not be accurate // when threads are used over federation. That could result in the reply @@ -229,13 +229,6 @@ export class Thread extends TypedEventEmitter { this._currentUserParticipated = true; } - if ([RelationType.Annotation, RelationType.Replace].includes(event.getRelation()?.rel_type as RelationType)) { - // Apply annotations and replace relations to the relations of the timeline only - this.timelineSet.setRelationsTarget(event); - this.timelineSet.aggregateRelations(event); - return; - } - // Add all incoming events to the thread's timeline set when there's no server support if (!Thread.hasServerSideSupport) { // all the relevant membership info to hydrate events with a sender @@ -251,6 +244,11 @@ export class Thread extends TypedEventEmitter { ) { this.fetchEditsWhereNeeded(event); this.addEventToTimeline(event, false); + } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { + // Apply annotations and replace relations to the relations of the timeline only + this.timelineSet.relations.aggregateParentEvent(event); + this.timelineSet.relations.aggregateChildEvent(event, this.timelineSet); + return; } // If no thread support exists we want to count all thread relation @@ -293,6 +291,7 @@ export class Thread extends TypedEventEmitter { // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => { + if (event.isRelation()) return; // skip - relations don't get edits return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), { limit: 1, }).then(relations => { diff --git a/src/sync.ts b/src/sync.ts index 69da493eb0f..4abd4fb5bb2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -202,13 +202,11 @@ export class SyncApi { const client = this.client; const { timelineSupport, - unstableClientRelationAggregation, } = client; const room = new Room(roomId, client, client.getUserId(), { lazyLoadMembers: this.opts.lazyLoadMembers, pendingEventOrdering: this.opts.pendingEventOrdering, timelineSupport, - unstableClientRelationAggregation, }); client.reEmitter.reEmit(room, [ RoomEvent.Name, From cb5b2e14703ecab96f89ce6945e60de75a1c4f54 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 7 Jun 2022 12:09:10 +0100 Subject: [PATCH 15/41] Resetting package fields for development --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index abd38314793..0cd570a1459 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "keywords": [ "matrix-org" ], - "main": "./lib/index.js", + "main": "./src/index.ts", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -125,6 +125,5 @@ "jestSonar": { "reportPath": "coverage", "sonar56x": true - }, - "typings": "./lib/index.d.ts" + } } From 96c35e2dd3e823ca8db1da2f88310615872d3a3a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 7 Jun 2022 08:52:08 -0500 Subject: [PATCH 16/41] Add more detail on the context/rationale that should be included when contributing (#2432) Follow-up to https://github.com/matrix-org/matrix-js-sdk/pull/1933 Spawning from various recent documents and comments: - https://github.com/vector-im/element-meta/wiki/Review-process - https://github.com/matrix-org/synapse/pull/12846#discussion_r887270734 - https://gitlab.matrix.org/new-vector/internal/-/wikis/Backend/Reviews --- CONTRIBUTING.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 158c916a3e2..7df3845e35d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,18 @@ Things that should go into your PR description: * A changelog entry in the `Notes` section (see below) * References to any bugs fixed by the change (in GitHub's `Fixes` notation) * Describe the why and what is changing in the PR description so it's easy for - onlookers and reviewers to onboard and context switch. + onlookers and reviewers to onboard and context switch. This information is + also helpful when we come back to look at this in 6 months and ask "why did + we do it like that?" we have a chance of finding out. + * Why didn't it work before? Why does it work now? What use cases does it + unlock? + * If you find yourself adding information on how the code works or why you + chose to do it the way you did, make sure this information is instead + written as comments in the code itself. + * Sometimes a PR can change considerably as it is developed. In this case, + the description should be updated to reflect the most recent state of + the PR. (It can be helpful to retain the old content under a suitable + heading, for additional context.) * Include both **before** and **after** screenshots to easily compare and discuss what's changing. * Include a step-by-step testing strategy so that a reviewer can check out the @@ -31,11 +42,6 @@ Things that should go into your PR description: * Add comments to the diff for the reviewer that might help them to understand why the change is necessary or how they might better understand and review it. -Things that should *not* go into your PR description: - * Any information on how the code works or why you chose to do it the way - you did. If this isn't obvious from your code, you haven't written enough - comments. - We rely on information in pull request to populate the information that goes into the changelogs our users see, both for the JS SDK itself and also for some projects based on it. This is picked up from both labels on the pull request and @@ -254,6 +260,12 @@ on Git 2.17+ you can mass signoff using rebase: git rebase --signoff origin/develop ``` +Review expectations +=================== + +See https://github.com/vector-im/element-meta/wiki/Review-process + + Merge Strategy ============== From 2982bd79f6102bb71f94a7143e8a20ac10d041e4 Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 7 Jun 2022 17:04:58 +0200 Subject: [PATCH 17/41] Live location sharing - monitor liveness of beacons yet to start (PSF-1081) (#2437) * monitor liveness of beacons yet to start * make watch interval a timeout instead --- spec/unit/models/beacon.spec.ts | 44 ++++++++++++++++++++++++++++++--- src/models/beacon.ts | 19 +++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index dc4058d1ce4..30052ae7ca1 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -71,7 +71,7 @@ describe('Beacon', () => { const advanceDateAndTime = (ms: number) => { // bc liveness check uses Date.now we have to advance this mock - jest.spyOn(global.Date, 'now').mockReturnValue(now + ms); + jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms); // then advance time for the interval by the same amount jest.advanceTimersByTime(ms); }; @@ -224,13 +224,49 @@ describe('Beacon', () => { beacon.monitorLiveness(); // @ts-ignore - expect(beacon.livenessWatchInterval).toBeFalsy(); + expect(beacon.livenessWatchTimeout).toBeFalsy(); advanceDateAndTime(HOUR_MS * 2 + 1); // no emit expect(emitSpy).not.toHaveBeenCalled(); }); + it('checks liveness of beacon at expected start time', () => { + // go forward in time to make beacon with timestamp in future + jest.spyOn(global.Date, 'now').mockReturnValue(now + HOUR_MS); + const futureBeaconEvent = makeBeaconInfoEvent( + userId, + roomId, + { + timeout: HOUR_MS * 3, + isLive: true, + }, + '$live123', + ); + // go back to now + jest.spyOn(global.Date, 'now').mockReturnValue(now); + + const beacon = new Beacon(futureBeaconEvent); + expect(beacon.isLive).toBeFalsy(); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.monitorLiveness(); + + // advance to the start timestamp of the beacon + advanceDateAndTime(HOUR_MS + 1); + + // beacon is in live period now + expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, true, beacon); + + // check the expiry monitor is still setup ok + // advance to the expiry + advanceDateAndTime(HOUR_MS * 3 + 100); + + expect(emitSpy).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon); + }); + it('checks liveness of beacon at expected expiry time', () => { // live beacon was created an hour ago // and has a 3hr duration @@ -253,12 +289,12 @@ describe('Beacon', () => { beacon.monitorLiveness(); // @ts-ignore - const oldMonitor = beacon.livenessWatchInterval; + const oldMonitor = beacon.livenessWatchTimeout; beacon.monitorLiveness(); // @ts-ignore - expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor); + expect(beacon.livenessWatchTimeout).not.toEqual(oldMonitor); }); it('destroy kills liveness monitor and emits', () => { diff --git a/src/models/beacon.ts b/src/models/beacon.ts index a4f7694588b..05562574322 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -54,7 +54,7 @@ export class Beacon extends TypedEventEmitter; + private livenessWatchTimeout: ReturnType; private _latestLocationState: BeaconLocationState | undefined; constructor( @@ -109,8 +109,8 @@ export class Beacon extends TypedEventEmitter 1) { - this.livenessWatchInterval = setInterval( + this.livenessWatchTimeout = setTimeout( () => { this.monitorLiveness(); }, expiryInMs, ); } + } else if (this._beaconInfo?.timestamp > Date.now()) { + // beacon start timestamp is in the future + // check liveness again then + this.livenessWatchTimeout = setTimeout( + () => { this.monitorLiveness(); }, + this.beaconInfo?.timestamp - Date.now(), + ); } } From 8f5162c40d67c713d954fb687d9f35d60938469c Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 8 Jun 2022 12:39:08 +0200 Subject: [PATCH 18/41] Test typescriptification - crypto unit tests pt 1 (#2440) * enamed: spec/unit/crypto/secrets.spec.js -> spec/unit/crypto/secrets.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in secrets.spec Signed-off-by: Kerry Archibald * renamed: spec/unit/crypto/outgoing-room-key-requests.spec.js -> spec/unit/crypto/outgoing-room-key-requests.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in outgoing-room-key-requests.spec.ts Signed-off-by: Kerry Archibald * renamed: spec/unit/crypto/DeviceList.spec.js -> spec/unit/crypto/DeviceList.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in DeviceList.spec Signed-off-by: Kerry Archibald --- ...{DeviceList.spec.js => DeviceList.spec.ts} | 31 +++++--- ....js => outgoing-room-key-requests.spec.ts} | 36 +++++---- .../{secrets.spec.js => secrets.spec.ts} | 77 +++++++++++-------- 3 files changed, 83 insertions(+), 61 deletions(-) rename spec/unit/crypto/{DeviceList.spec.js => DeviceList.spec.ts} (89%) rename spec/unit/crypto/{outgoing-room-key-requests.spec.js => outgoing-room-key-requests.spec.ts} (73%) rename spec/unit/crypto/{secrets.spec.js => secrets.spec.ts} (91%) diff --git a/spec/unit/crypto/DeviceList.spec.js b/spec/unit/crypto/DeviceList.spec.ts similarity index 89% rename from spec/unit/crypto/DeviceList.spec.js rename to spec/unit/crypto/DeviceList.spec.ts index bb113dccf99..cb7f0fb0fe8 100644 --- a/spec/unit/crypto/DeviceList.spec.js +++ b/spec/unit/crypto/DeviceList.spec.ts @@ -1,7 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018, 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,8 +20,10 @@ import { logger } from "../../../src/logger"; import * as utils from "../../../src/utils"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; import { DeviceList } from "../../../src/crypto/DeviceList"; +import { IDownloadKeyResult, MatrixClient } from "../../../src"; +import { OlmDevice } from "../../../src/crypto/OlmDevice"; -const signedDeviceList = { +const signedDeviceList: IDownloadKeyResult = { "failures": {}, "device_keys": { "@test1:sw1v.org": { @@ -45,13 +47,15 @@ const signedDeviceList = { "m.megolm.v1.aes-sha2", ], "device_id": "HGKAWHRVJQ", - "unsigned": {}, + "unsigned": { + "device_display_name": "", + }, }, }, }, }; -const signedDeviceList2 = { +const signedDeviceList2: IDownloadKeyResult = { "failures": {}, "device_keys": { "@test2:sw1v.org": { @@ -75,7 +79,9 @@ const signedDeviceList2 = { "m.megolm.v1.aes-sha2", ], "device_id": "QJVRHWAKGH", - "unsigned": {}, + "unsigned": { + "device_display_name": "", + }, }, }, }, @@ -104,10 +110,10 @@ describe('DeviceList', function() { downloadKeysForUsers: downloadSpy, getUserId: () => '@test1:sw1v.org', deviceId: 'HGKAWHRVJQ', - }; + } as unknown as MatrixClient; const mockOlm = { verifySignature: function(key, message, signature) {}, - }; + } as unknown as OlmDevice; const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); deviceLists.push(dl); return dl; @@ -118,7 +124,7 @@ describe('DeviceList', function() { dl.startTrackingDeviceList('@test1:sw1v.org'); - const queryDefer1 = utils.defer(); + const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); @@ -138,7 +144,7 @@ describe('DeviceList', function() { dl.startTrackingDeviceList('@test1:sw1v.org'); - const queryDefer1 = utils.defer(); + const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); @@ -155,6 +161,7 @@ describe('DeviceList', function() { dl.saveIfDirty().then(() => { // the first request completes queryDefer1.resolve({ + failures: {}, device_keys: { '@test1:sw1v.org': {}, }, @@ -166,7 +173,7 @@ describe('DeviceList', function() { logger.log("Creating new devicelist to simulate app reload"); downloadSpy.mockReset(); const dl2 = createTestDeviceList(); - const queryDefer3 = utils.defer(); + const queryDefer3 = utils.defer(); downloadSpy.mockReturnValue(queryDefer3.promise); const prom3 = dl2.refreshOutdatedDeviceLists(); @@ -190,9 +197,9 @@ describe('DeviceList', function() { dl.startTrackingDeviceList('@test1:sw1v.org'); dl.startTrackingDeviceList('@test2:sw1v.org'); - const queryDefer1 = utils.defer(); + const queryDefer1 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer1.promise); - const queryDefer2 = utils.defer(); + const queryDefer2 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer2.promise); const prom1 = dl.refreshOutdatedDeviceLists(); diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.js b/spec/unit/crypto/outgoing-room-key-requests.spec.ts similarity index 73% rename from spec/unit/crypto/outgoing-room-key-requests.spec.js rename to spec/unit/crypto/outgoing-room-key-requests.spec.ts index 4a18e176536..c572a63ebef 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.js +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.ts @@ -43,13 +43,15 @@ const requests = [ describe.each([ ["IndexedDBCryptoStore", - () => new IndexedDBCryptoStore(global.indexedDB, "tests")], + () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", - () => new IndexedDBCryptoStore(undefined, "tests")], + () => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => { const store = new IndexedDBCryptoStore(undefined, "tests"); - store._backend = new MemoryCryptoStore(); - store._backendPromise = Promise.resolve(store._backend); + // @ts-ignore set private properties + store.backend = new MemoryCryptoStore(); + // @ts-ignore + store.backendPromise = Promise.resolve(store.backend); return store; }], ])("Outgoing room key requests [%s]", function(name, dbFactory) { @@ -64,22 +66,22 @@ describe.each([ }); it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", - async () => { - const r = await + async () => { + const r = await store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); - expect(r).toHaveLength(2); - requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { - expect(r).toContainEqual(e); + expect(r).toHaveLength(2); + requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { + expect(r).toContainEqual(e); + }); }); - }); test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", - async () => { - const r = + async () => { + const r = await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); - expect(r).not.toBeNull(); - expect(r).not.toBeUndefined(); - expect(r.state).toEqual(RoomKeyRequestState.Sent); - expect(requests).toContainEqual(r); - }); + expect(r).not.toBeNull(); + expect(r).not.toBeUndefined(); + expect(r.state).toEqual(RoomKeyRequestState.Sent); + expect(requests).toContainEqual(r); + }); }); diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.ts similarity index 91% rename from spec/unit/crypto/secrets.spec.js rename to spec/unit/crypto/secrets.spec.ts index 6a1e41f4003..1ed9c21e2ac 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,15 +24,18 @@ import { encryptAES } from "../../../src/crypto/aes"; import { resetCrossSigningKeys, createSecretStorageKey } from "./crypto-utils"; import { logger } from '../../../src/logger'; import * as utils from "../../../src/utils"; +import { ICreateClientOpts } from '../../../src'; +import { ISecretStorageKeyInfo } from '../../../src/crypto/api'; try { + // eslint-disable-next-line @typescript-eslint/no-var-requires const crypto = require('crypto'); utils.setCrypto(crypto); } catch (err) { logger.log('nodejs was compiled without crypto support'); } -async function makeTestClient(userInfo, options) { +async function makeTestClient(userInfo: { userId: string, deviceId: string}, options: Partial = {}) { const client = (new TestClient( userInfo.userId, userInfo.deviceId, undefined, undefined, options, )).client; @@ -46,7 +49,7 @@ async function makeTestClient(userInfo, options) { await client.initCrypto(); // No need to download keys for these tests - client.crypto.downloadKeys = async function() {}; + jest.spyOn(client.crypto, 'downloadKeys').mockResolvedValue({}); return client; } @@ -54,7 +57,7 @@ async function makeTestClient(userInfo, options) { // Wrapper around pkSign to return a signed object. pkSign returns the // signature, rather than the signed object. function sign(obj, key, userId) { - olmlib.pkSign(obj, key, userId); + olmlib.pkSign(obj, key, userId, ''); return obj; } @@ -84,7 +87,7 @@ describe("Secrets", function() { }, }; - const getKey = jest.fn(e => { + const getKey = jest.fn().mockImplementation(async e => { expect(Object.keys(e.keys)).toEqual(["abc"]); return ['abc', key]; }); @@ -93,7 +96,7 @@ describe("Secrets", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => signingKey, + getCrossSigningKey: async t => signingKey, getSecretStorageKey: getKey, }, }, @@ -104,17 +107,19 @@ describe("Secrets", function() { const secretStorage = alice.crypto.secretStorage; - alice.setAccountData = async function(eventType, contents, callback) { - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: eventType, - content: contents, - }), - ]); - if (callback) { - callback(); - } - }; + jest.spyOn(alice, 'setAccountData').mockImplementation( + async function(eventType, contents, callback) { + alice.store.storeAccountDataEvents([ + new MatrixEvent({ + type: eventType, + content: contents, + }), + ]); + if (callback) { + callback(undefined, undefined); + } + return {}; + }); const keyAccountData = { algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, @@ -170,7 +175,7 @@ describe("Secrets", function() { it("should encrypt with default key if keys is null", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn(e => { + const getKey = jest.fn().mockImplementation(async e => { expect(Object.keys(e.keys)).toEqual([newKeyId]); return [newKeyId, key]; }); @@ -193,11 +198,12 @@ describe("Secrets", function() { content: contents, }), ]); + return {}; }; resetCrossSigningKeys(alice); const { keyId: newKeyId } = await alice.addSecretStorageKey( - SECRET_STORAGE_ALGORITHM_V1_AES, + SECRET_STORAGE_ALGORITHM_V1_AES, { pubkey: undefined, key: undefined }, ); // we don't await on this because it waits for the event to come down the sync // which won't happen in the test setup @@ -306,7 +312,7 @@ describe("Secrets", function() { it("bootstraps when no storage or cross-signing keys locally", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn(e => { + const getKey = jest.fn().mockImplementation(async e => { return [Object.keys(e.keys)[0], key]; }); @@ -321,8 +327,8 @@ describe("Secrets", function() { }, }, ); - bob.uploadDeviceSigningKeys = async () => {}; - bob.uploadKeySignatures = async () => {}; + bob.uploadDeviceSigningKeys = async () => ({}); + bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined); bob.setAccountData = async function(eventType, contents, callback) { const event = new MatrixEvent({ type: eventType, @@ -332,10 +338,11 @@ describe("Secrets", function() { event, ]); this.emit("accountData", event); + return {}; }; await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => await func({}), + authUploadDeviceSigningKeys: async func => { await func({}); }, }); await bob.bootstrapSecretStorage({ createSecretStorageKey, @@ -419,7 +426,7 @@ describe("Secrets", function() { }); it("adds passphrase checking if it's lacking", async function() { - let crossSigningKeys = { + let crossSigningKeys: Record = { master: XSK, user_signing: USK, self_signing: SSK, @@ -431,9 +438,9 @@ describe("Secrets", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => crossSigningKeys[t], + getCrossSigningKey: async t => crossSigningKeys[t], saveCrossSigningKeys: k => crossSigningKeys = k, - getSecretStorageKey: ({ keys }, name) => { + getSecretStorageKey: async ({ keys }, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { return [keyId, secretStorageKeys[keyId]]; @@ -489,6 +496,8 @@ describe("Secrets", function() { }), ]); alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { + firstUse: false, + crossSigningVerifiedBefore: false, keys: { master: { user_id: "@alice:example.com", @@ -529,14 +538,15 @@ describe("Secrets", function() { }); alice.store.storeAccountDataEvents([event]); this.emit("accountData", event); + return {}; }; - await alice.bootstrapSecretStorage(); + await alice.bootstrapSecretStorage({}); expect(alice.getAccountData("m.secret_storage.default_key").getContent()) .toEqual({ key: "key_id" }); const keyInfo = alice.getAccountData("m.secret_storage.key.key_id") - .getContent(); + .getContent() as ISecretStorageKeyInfo; expect(keyInfo.algorithm) .toEqual("m.secret_storage.v1.aes-hmac-sha2"); expect(keyInfo.passphrase).toEqual({ @@ -551,7 +561,7 @@ describe("Secrets", function() { alice.stopClient(); }); it("fixes backup keys in the wrong format", async function() { - let crossSigningKeys = { + let crossSigningKeys: Record = { master: XSK, user_signing: USK, self_signing: SSK, @@ -563,9 +573,9 @@ describe("Secrets", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => crossSigningKeys[t], + getCrossSigningKey: async t => crossSigningKeys[t], saveCrossSigningKeys: k => crossSigningKeys = k, - getSecretStorageKey: ({ keys }, name) => { + getSecretStorageKey: async ({ keys }, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { return [keyId, secretStorageKeys[keyId]]; @@ -630,6 +640,8 @@ describe("Secrets", function() { }), ]); alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { + firstUse: false, + crossSigningVerifiedBefore: false, keys: { master: { user_id: "@alice:example.com", @@ -670,9 +682,10 @@ describe("Secrets", function() { }); alice.store.storeAccountDataEvents([event]); this.emit("accountData", event); + return {}; }; - await alice.bootstrapSecretStorage(); + await alice.bootstrapSecretStorage({}); const backupKey = alice.getAccountData("m.megolm_backup.v1") .getContent(); From 11cc2aca9dc12296d35f23cb5b425dfa4f98caae Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jun 2022 11:43:36 +0100 Subject: [PATCH 19/41] Add CI to improve experience for community (#2439) * Add CI to improve experience for community * Fix close-if-fork-develop if-condition * Extract into reusable workflow * Update pull_request.yaml --- .github/workflows/pull_request.yaml | 78 +++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index f6e86d1531f..be66f4f7548 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -2,6 +2,13 @@ name: Pull Request on: pull_request_target: types: [ opened, edited, labeled, unlabeled, synchronize ] + workflow_call: + inputs: + labels: + type: string + default: "T-Defect,T-Deprecation,T-Enhancement,T-Task" + required: true + description: "One of these provided labels must be present" concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }} jobs: changelog: @@ -21,6 +28,71 @@ jobs: steps: - uses: yogevbd/enforce-label-action@2.2.2 with: - REQUIRED_LABELS_ANY: "T-Defect,T-Deprecation,T-Enhancement,T-Task" - BANNED_LABELS: "X-Blocked" - BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!" + REQUIRED_LABELS_ANY: ${{ inputs.labels }} + + prevent-blocked: + name: Prevent Blocked + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - name: Add notice + uses: actions/github-script@v5 + if: contains(github.event.pull_request.labels.*.name, 'X-Blocked') + with: + script: | + core.setFailed("Preventing merge whilst PR is marked blocked!"); + + community-prs: + name: Label Community PRs + runs-on: ubuntu-latest + if: github.event.action == 'opened' + steps: + - name: Check membership + uses: tspascoal/get-user-teams-membership@v1 + id: teams + with: + username: ${{ github.event.pull_request.user.login }} + organization: matrix-org + team: core-team + GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + + - name: Add label + if: ${{ steps.teams.outputs.isTeamMember == 'false' }} + uses: actions/github-script@v5 + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['Z-Community-PR'] + }); + + close-if-fork-develop: + name: Forbid develop branch fork contributions + runs-on: ubuntu-latest + if: > + github.event.action == 'opened' && + github.event.pull_request.head.ref == 'develop' && + github.event.pull_request.head.repo.full_name != github.repository + steps: + - name: Close pull request + uses: actions/github-script@v5 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" + + " branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity." + + " See https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md", + }); + + github.rest.pulls.update({ + pull_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed' + }); From b20063f8a8ef5044ae2b06f8825cdc2bc5c93421 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jun 2022 13:07:38 +0100 Subject: [PATCH 20/41] Pass missing ci secret (#2442) * Update pull_request.yaml * Update pull_request.yaml --- .github/workflows/pull_request.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index be66f4f7548..59aa4557665 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -9,6 +9,9 @@ on: default: "T-Defect,T-Deprecation,T-Enhancement,T-Task" required: true description: "One of these provided labels must be present" + secrets: + ELEMENT_BOT_TOKEN: + required: true concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }} jobs: changelog: @@ -54,7 +57,7 @@ jobs: with: username: ${{ github.event.pull_request.user.login }} organization: matrix-org - team: core-team + team: Core Team GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} - name: Add label From 2c2686c910b6317fa26b32665595a8bcbb10bf1b Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 8 Jun 2022 15:03:02 +0200 Subject: [PATCH 21/41] Test typescriptification - cross-signing.spec (#2441) * enamed: spec/unit/crypto/secrets.spec.js -> spec/unit/crypto/secrets.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in secrets.spec Signed-off-by: Kerry Archibald * renamed: spec/unit/crypto/outgoing-room-key-requests.spec.js -> spec/unit/crypto/outgoing-room-key-requests.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in outgoing-room-key-requests.spec.ts Signed-off-by: Kerry Archibald * renamed: spec/unit/crypto/DeviceList.spec.js -> spec/unit/crypto/DeviceList.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in DeviceList.spec Signed-off-by: Kerry Archibald * renamed: spec/unit/crypto/CrossSigningInfo.spec.js -> spec/unit/crypto/CrossSigningInfo.spec.ts Signed-off-by: Kerry Archibald * fix ts issues in CrossSigningInfo.spec Signed-off-by: Kerry Archibald * renamed: spec/unit/crypto/cross-signing.spec.js -> spec/unit/crypto/cross-signing.spec.ts Signed-off-by: Kerry Archibald * fix most function call types in cross-signing.spec Signed-off-by: Kerry Archibald * typed events in cross-signing Signed-off-by: Kerry Archibald * type cross signing keys Signed-off-by: Kerry Archibald * convince the rest of the key types in cross-signing.spec Signed-off-by: Kerry Archibald * use correct type for IDevice --- ...gInfo.spec.js => CrossSigningInfo.spec.ts} | 114 ++++---- ...-signing.spec.js => cross-signing.spec.ts} | 257 ++++++++++-------- spec/unit/crypto/secrets.spec.ts | 2 +- 3 files changed, 210 insertions(+), 163 deletions(-) rename spec/unit/crypto/{CrossSigningInfo.spec.js => CrossSigningInfo.spec.ts} (72%) rename spec/unit/crypto/{cross-signing.spec.js => cross-signing.spec.ts} (84%) diff --git a/spec/unit/crypto/CrossSigningInfo.spec.js b/spec/unit/crypto/CrossSigningInfo.spec.ts similarity index 72% rename from spec/unit/crypto/CrossSigningInfo.spec.js rename to spec/unit/crypto/CrossSigningInfo.spec.ts index 5c3ebade12c..9ed50a60c73 100644 --- a/spec/unit/crypto/CrossSigningInfo.spec.js +++ b/spec/unit/crypto/CrossSigningInfo.spec.ts @@ -66,23 +66,23 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { }); it.each(types)("should throw if the callback returns falsey", - async ({ type, shouldCache }) => { - const info = new CrossSigningInfo(userId, { - getCrossSigningKey: () => false, + async ({ type, shouldCache }) => { + const info = new CrossSigningInfo(userId, { + getCrossSigningKey: async () => false as unknown as Uint8Array, + }); + await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); }); - await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); - }); it("should throw if the expected key doesn't come back", async () => { const info = new CrossSigningInfo(userId, { - getCrossSigningKey: () => masterKeyPub, + getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array, }); await expect(info.getCrossSigningKey("master", "")).rejects.toThrow(); }); it("should return a key from its callback", async () => { const info = new CrossSigningInfo(userId, { - getCrossSigningKey: () => testKey, + getCrossSigningKey: async () => testKey, }); const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub); expect(pubKey).toEqual(masterKeyPub); @@ -99,7 +99,7 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { it.each(types)("should request a key from the cache callback (if set)" + " and does not call app if one is found" + " %o", - async ({ type, shouldCache }) => { + async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockImplementation(() => { if (shouldCache) { return Promise.reject(new Error("Regular callback called")); @@ -122,58 +122,58 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { }); it.each(types)("should store a key with the cache callback (if set)", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { storeCrossSigningKeyCache }, - ); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); - if (shouldCache) { - expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); - expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); - } - }); + async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); + const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const info = new CrossSigningInfo( + userId, + { getCrossSigningKey }, + { storeCrossSigningKeyCache }, + ); + const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); + expect(pubKey).toEqual(masterKeyPub); + expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); + if (shouldCache) { + expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); + expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); + } + }); it.each(types)("does not store a bad key to the cache", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { storeCrossSigningKeyCache }, - ); - await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); - }); + async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); + const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const info = new CrossSigningInfo( + userId, + { getCrossSigningKey }, + { storeCrossSigningKeyCache }, + ); + await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); + expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); + }); it.each(types)("does not store a value to the cache if it came from the cache", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockImplementation(() => { - if (shouldCache) { - return Promise.reject(new Error("Regular callback called")); - } else { - return Promise.resolve(testKey); - } + const getCrossSigningKey = jest.fn().mockImplementation(() => { + if (shouldCache) { + return Promise.reject(new Error("Regular callback called")); + } else { + return Promise.resolve(testKey); + } + }); + const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); + const storeCrossSigningKeyCache = jest.fn().mockRejectedValue( + new Error("Tried to store a value from cache"), + ); + const info = new CrossSigningInfo( + userId, + { getCrossSigningKey }, + { getCrossSigningKeyCache, storeCrossSigningKeyCache }, + ); + expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); + const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); + expect(pubKey).toEqual(masterKeyPub); }); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockRejectedValue( - new Error("Tried to store a value from cache"), - ); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - }); it.each(types)("requests a key from the cache callback (if set) and then calls app" + " if one is not found", async ({ type, shouldCache }) => { @@ -220,12 +220,14 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { */ describe.each([ ["IndexedDBCryptoStore", - () => new IndexedDBCryptoStore(global.indexedDB, "tests")], + () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", - () => new IndexedDBCryptoStore(undefined, "tests")], + () => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => { const store = new IndexedDBCryptoStore(undefined, "tests"); + // @ts-ignore set private properties store._backend = new MemoryCryptoStore(); + // @ts-ignore store._backendPromise = Promise.resolve(store._backend); return store; }], diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.ts similarity index 84% rename from spec/unit/crypto/cross-signing.spec.js rename to spec/unit/crypto/cross-signing.spec.ts index 6240caed10d..30c1bf82ce3 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.ts @@ -17,12 +17,16 @@ limitations under the License. import '../../olm-loader'; import anotherjson from 'another-json'; +import { PkSigning } from '@matrix-org/olm'; import * as olmlib from "../../../src/crypto/olmlib"; -import { TestClient } from '../../TestClient'; -import { resetCrossSigningKeys } from "./crypto-utils"; import { MatrixError } from '../../../src/http-api'; import { logger } from '../../../src/logger'; +import { ICrossSigningKey, ICreateClientOpts, ISignedKey } from '../../../src/client'; +import { CryptoEvent } from '../../../src/crypto'; +import { IDevice } from '../../../src/crypto/deviceinfo'; +import { TestClient } from '../../TestClient'; +import { resetCrossSigningKeys } from "./crypto-utils"; const PUSH_RULES_RESPONSE = { method: "GET", @@ -47,9 +51,11 @@ function setHttpResponses(httpBackend, responses) { }); } -async function makeTestClient(userInfo, options, keys) { - if (!keys) keys = {}; - +async function makeTestClient( + userInfo: { userId: string, deviceId: string}, + options: Partial = {}, + keys = {}, +) { function getCrossSigningKey(type) { return keys[type]; } @@ -58,7 +64,6 @@ async function makeTestClient(userInfo, options, keys) { Object.assign(keys, k); } - if (!options) options = {}; options.cryptoCallbacks = Object.assign( {}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {}, ); @@ -86,18 +91,18 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => { + alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => { await olmlib.verifySignature( alice.crypto.olmDevice, keys.master_key, "@alice:example.com", "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); }); - alice.uploadKeySignatures = async () => {}; - alice.setAccountData = async () => {}; - alice.getAccountDataFromServer = async () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); + alice.setAccountData = async () => ({}); + alice.getAccountDataFromServer = async () => ({} as T); // set Alice's cross-signing key await alice.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => await func({}), + authUploadDeviceSigningKeys: async func => { await func({}); }, }); expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); alice.stopClient(); @@ -134,9 +139,9 @@ describe("Cross Signing", function() { error.httpStatus == 401; throw error; }; - alice.uploadKeySignatures = async () => {}; - alice.setAccountData = async () => {}; - alice.getAccountDataFromServer = async () => { }; + alice.uploadKeySignatures = async () => ({ failures: {} }); + alice.setAccountData = async () => ({}); + alice.getAccountDataFromServer = async (): Promise => ({} as T); const authUploadDeviceSigningKeys = async func => await func({}); // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass @@ -159,8 +164,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's device key @@ -174,11 +179,14 @@ describe("Cross Signing", function() { }, }, }, + firstUse: false, + crossSigningVerifiedBefore: false, }); // Alice verifies Bob's key const promise = new Promise((resolve, reject) => { - alice.uploadKeySignatures = (...args) => { + alice.uploadKeySignatures = async (...args) => { resolve(...args); + return { failures: {} }; }; }); await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true); @@ -206,7 +214,7 @@ describe("Cross Signing", function() { { cryptoCallbacks: { // will be called to sign our own device - getCrossSigningKey: type => { + getCrossSigningKey: async type => { if (type === 'master') { return masterKey; } else { @@ -218,7 +226,7 @@ describe("Cross Signing", function() { ); const keyChangePromise = new Promise((resolve, reject) => { - alice.once("crossSigning.keysChanged", async (e) => { + alice.once(CryptoEvent.KeysChanged, async (e) => { resolve(e); await alice.checkOwnCrossSigningTrust({ allowPrivateKeyRequests: true, @@ -226,14 +234,14 @@ describe("Cross Signing", function() { }); }); - const uploadSigsPromise = new Promise((resolve, reject) => { - alice.uploadKeySignatures = jest.fn(async (content) => { + const uploadSigsPromise = new Promise((resolve, reject) => { + alice.uploadKeySignatures = jest.fn().mockImplementation(async (content) => { try { await olmlib.verifySignature( alice.crypto.olmDevice, content["@alice:example.com"][ "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" - ], + ], "@alice:example.com", "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); @@ -249,16 +257,22 @@ describe("Cross Signing", function() { }); }); + // @ts-ignore private property const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", + keys: deviceInfo.keys, + algorithms: deviceInfo.algorithms, }; - aliceDevice.keys = deviceInfo.keys; - aliceDevice.algorithms = deviceInfo.algorithms; await alice.crypto.signObject(aliceDevice); - olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com"); + olmlib.pkSign( + aliceDevice as ISignedKey, + selfSigningKey as unknown as PkSigning, + "@alice:example.com", + '', + ); // feed sync result that includes master key, ssk, device key const responses = [ @@ -363,8 +377,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's ssk and device key @@ -374,7 +388,7 @@ describe("Cross Signing", function() { const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK = { + const bobSSK: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -398,10 +412,10 @@ describe("Cross Signing", function() { }, self_signing: bobSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); - const bobDevice = { + const bobDeviceUnsigned = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], @@ -410,11 +424,16 @@ describe("Cross Signing", function() { "ed25519:Dynabook": "someOtherPubkey", }, }; - const sig = bobSigning.sign(anotherjson.stringify(bobDevice)); - bobDevice.signatures = { - "@bob:example.com": { - ["ed25519:" + bobPubkey]: sig, + const sig = bobSigning.sign(anotherjson.stringify(bobDeviceUnsigned)); + const bobDevice: IDevice = { + ...bobDeviceUnsigned, + signatures: { + "@bob:example.com": { + ["ed25519:" + bobPubkey]: sig, + }, }, + verified: 0, + known: false, }; alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, @@ -429,7 +448,7 @@ describe("Cross Signing", function() { expect(bobDeviceTrust.isTofu()).toBeTruthy(); // Alice verifies Bob's SSK - alice.uploadKeySignatures = () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be trusted @@ -445,7 +464,7 @@ describe("Cross Signing", function() { }); it.skip("should trust signatures received from other devices", async function() { - const aliceKeys = {}; + const aliceKeys: Record = {}; const { client: alice, httpBackend } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, null, @@ -453,8 +472,8 @@ describe("Cross Signing", function() { ); alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com"); alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {}; - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); @@ -466,28 +485,29 @@ describe("Cross Signing", function() { 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, ]); - const keyChangePromise = new Promise((resolve, reject) => { - alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => { + const keyChangePromise = new Promise((resolve, reject) => { + alice.crypto.deviceList.once(CryptoEvent.UserCrossSigningUpdated, (userId) => { if (userId === "@bob:example.com") { resolve(); } }); }); + // @ts-ignore private property const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", + keys: deviceInfo.keys, + algorithms: deviceInfo.algorithms, }; - aliceDevice.keys = deviceInfo.keys; - aliceDevice.algorithms = deviceInfo.algorithms; await alice.crypto.signObject(aliceDevice); const bobOlmAccount = new global.Olm.Account(); bobOlmAccount.create(); const bobKeys = JSON.parse(bobOlmAccount.identity_keys()); - const bobDevice = { + const bobDeviceUnsigned = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], @@ -496,15 +516,25 @@ describe("Cross Signing", function() { "curve25519:Dynabook": bobKeys.curve25519, }, }; - const deviceStr = anotherjson.stringify(bobDevice); - bobDevice.signatures = { - "@bob:example.com": { - "ed25519:Dynabook": bobOlmAccount.sign(deviceStr), + const deviceStr = anotherjson.stringify(bobDeviceUnsigned); + const bobDevice: IDevice = { + ...bobDeviceUnsigned, + signatures: { + "@bob:example.com": { + "ed25519:Dynabook": bobOlmAccount.sign(deviceStr), + }, }, + verified: 0, + known: false, }; - olmlib.pkSign(bobDevice, selfSigningKey, "@bob:example.com"); + olmlib.pkSign( + bobDevice, + selfSigningKey as unknown as PkSigning, + "@bob:example.com", + '', + ); - const bobMaster = { + const bobMaster: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["master"], keys: { @@ -512,7 +542,7 @@ describe("Cross Signing", function() { "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", }, }; - olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com"); + olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", ''); // Alice downloads Bob's keys // - device key @@ -612,8 +642,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's ssk and device key @@ -624,7 +654,7 @@ describe("Cross Signing", function() { const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK = { + const bobSSK: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -648,8 +678,8 @@ describe("Cross Signing", function() { }, self_signing: bobSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); const bobDevice = { user_id: "@bob:example.com", @@ -661,7 +691,7 @@ describe("Cross Signing", function() { }, }; alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: bobDevice, + Dynabook: bobDevice as unknown as IDevice, }); // Bob's device key should be untrusted const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); @@ -682,8 +712,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); await resetCrossSigningKeys(alice); // Alice downloads Bob's keys const bobMasterSigning = new global.Olm.PkSigning(); @@ -692,7 +722,7 @@ describe("Cross Signing", function() { const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK = { + const bobSSK: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -716,10 +746,10 @@ describe("Cross Signing", function() { }, self_signing: bobSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); - const bobDevice = { + const bobDeviceUnsigned = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], @@ -728,16 +758,23 @@ describe("Cross Signing", function() { "ed25519:Dynabook": "someOtherPubkey", }, }; - const bobDeviceString = anotherjson.stringify(bobDevice); + const bobDeviceString = anotherjson.stringify(bobDeviceUnsigned); const sig = bobSigning.sign(bobDeviceString); - bobDevice.signatures = {}; - bobDevice.signatures["@bob:example.com"] = {}; - bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig; + const bobDevice: IDevice = { + ...bobDeviceUnsigned, + verified: 0, + known: false, + signatures: { + "@bob:example.com": { + ["ed25519:" + bobPubkey]: sig, + }, + }, + }; alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Alice verifies Bob's SSK - alice.uploadKeySignatures = () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be trusted @@ -752,7 +789,7 @@ describe("Cross Signing", function() { const bobSigning2 = new global.Olm.PkSigning(); const bobPrivkey2 = bobSigning2.generate_seed(); const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2); - const bobSSK2 = { + const bobSSK2: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -776,8 +813,8 @@ describe("Cross Signing", function() { }, self_signing: bobSSK2, }, - firstUse: 0, - unsigned: {}, + firstUse: false, + crossSigningVerifiedBefore: false, }); // Bob's and his device should be untrusted const bobTrust = alice.checkUserTrust("@bob:example.com"); @@ -789,7 +826,7 @@ describe("Cross Signing", function() { expect(bobDeviceTrust2.isTofu()).toBeFalsy(); // Alice verifies Bob's SSK - alice.uploadKeySignatures = () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true); // Bob should be trusted but not his device @@ -822,20 +859,21 @@ describe("Cross Signing", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - shouldUpgradeDeviceVerifications: (verifs) => { + shouldUpgradeDeviceVerifications: async (verifs) => { expect(verifs.users["@bob:example.com"]).toBeDefined(); upgradeResolveFunc(); return ["@bob:example.com"]; }, }, }, + ); const { client: bob } = await makeTestClient( { userId: "@bob:example.com", deviceId: "Dynabook" }, ); - bob.uploadDeviceSigningKeys = async () => {}; - bob.uploadKeySignatures = async () => {}; + bob.uploadDeviceSigningKeys = async () => ({}); + bob.uploadKeySignatures = async () => ({ failures: {} }); // set Bob's cross-signing key await resetCrossSigningKeys(bob); alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { @@ -854,8 +892,8 @@ describe("Cross Signing", function() { bob.crypto.crossSigningInfo.toStorage(), ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // when alice sets up cross-signing, she should notice that bob's // cross-signing key is signed by his Dynabook, which alice has // verified, and ask if the device verification should be upgraded to a @@ -881,9 +919,9 @@ describe("Cross Signing", function() { upgradePromise = new Promise((resolve) => { upgradeResolveFunc = resolve; }); - alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); + alice.crypto.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, "@bob:example.com"); await new Promise((resolve) => { - alice.crypto.on("userTrustStatusChanged", resolve); + alice.crypto.on(CryptoEvent.UserTrustStatusChanged, resolve); }); await upgradePromise; @@ -900,8 +938,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // Generate Alice's SSK etc const aliceMasterSigning = new global.Olm.PkSigning(); @@ -910,7 +948,7 @@ describe("Cross Signing", function() { const aliceSigning = new global.Olm.PkSigning(); const alicePrivkey = aliceSigning.generate_seed(); const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK = { + const aliceSSK: ICrossSigningKey = { user_id: "@alice:example.com", usage: ["self_signing"], keys: { @@ -936,34 +974,41 @@ describe("Cross Signing", function() { }, self_signing: aliceSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); // Alice has a second device that's cross-signed - const aliceCrossSignedDevice = { + const aliceDeviceId = 'Dynabook'; + const aliceUnsignedDevice = { user_id: "@alice:example.com", - device_id: "Dynabook", + device_id: aliceDeviceId, algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", "ed25519:Dynabook": "someOtherPubkey", }, }; - const sig = aliceSigning.sign(anotherjson.stringify(aliceCrossSignedDevice)); - aliceCrossSignedDevice.signatures = { - "@alice:example.com": { - ["ed25519:" + alicePubkey]: sig, - }, - }; + const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice)); + const aliceCrossSignedDevice: IDevice = { + ...aliceUnsignedDevice, + verified: 0, + known: false, + signatures: { + "@alice:example.com": { + ["ed25519:" + alicePubkey]: sig, + }, + } }; alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", { - Dynabook: aliceCrossSignedDevice, + [aliceDeviceId]: aliceCrossSignedDevice, }); // We don't trust the cross-signing keys yet... - expect(alice.checkDeviceTrust(aliceCrossSignedDevice.device_id).isCrossSigningVerified()).toBeFalsy(); + expect( + alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified(), + ).toBeFalsy(); // ... but we do acknowledge that the device is signed by them - expect(alice.checkIfOwnDeviceCrossSigned(aliceCrossSignedDevice.device_id)).toBeTruthy(); + expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy(); alice.stopClient(); }, ); @@ -972,8 +1017,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // Generate Alice's SSK etc const aliceMasterSigning = new global.Olm.PkSigning(); @@ -982,7 +1027,7 @@ describe("Cross Signing", function() { const aliceSigning = new global.Olm.PkSigning(); const alicePrivkey = aliceSigning.generate_seed(); const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK = { + const aliceSSK: ICrossSigningKey = { user_id: "@alice:example.com", usage: ["self_signing"], keys: { @@ -1008,14 +1053,14 @@ describe("Cross Signing", function() { }, self_signing: aliceSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); - // Alice has a second device that's also not cross-signed - const aliceNotCrossSignedDevice = { - user_id: "@alice:example.com", - device_id: "Dynabook", + const deviceId = "Dynabook"; + const aliceNotCrossSignedDevice: IDevice = { + verified: 0, + known: false, algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", @@ -1023,10 +1068,10 @@ describe("Cross Signing", function() { }, }; alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", { - Dynabook: aliceNotCrossSignedDevice, + [deviceId]: aliceNotCrossSignedDevice, }); - expect(alice.checkIfOwnDeviceCrossSigned(aliceNotCrossSignedDevice.device_id)).toBeFalsy(); + expect(alice.checkIfOwnDeviceCrossSigned(deviceId)).toBeFalsy(); alice.stopClient(); }); }); diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 1ed9c21e2ac..7939cdae2d1 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -24,7 +24,7 @@ import { encryptAES } from "../../../src/crypto/aes"; import { resetCrossSigningKeys, createSecretStorageKey } from "./crypto-utils"; import { logger } from '../../../src/logger'; import * as utils from "../../../src/utils"; -import { ICreateClientOpts } from '../../../src'; +import { ICreateClientOpts } from '../../../src/client'; import { ISecretStorageKeyInfo } from '../../../src/crypto/api'; try { From 8e896c4da3f91d60cd632dba0456f943dcec9db3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jun 2022 23:11:22 +0100 Subject: [PATCH 22/41] Fix issues with getEventTimeline and thread roots (#2444) * Add additional tests for thread timelines * Fix issues around mixing up event timeline sets with /context/ API * Increase coverage * Increase coverage * Better scope assertions * Iterate PR --- .../matrix-client-event-timeline.spec.js | 91 ++++++++++++++++++- src/client.ts | 61 +++++++------ src/models/event-timeline-set.ts | 7 +- src/models/event.ts | 2 +- src/models/thread.ts | 2 +- 5 files changed, 130 insertions(+), 33 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 454a4d30323..420fee6e588 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -1,5 +1,5 @@ import * as utils from "../test-utils/test-utils"; -import { EventTimeline } from "../../src/matrix"; +import { EventTimeline, Filter, MatrixEvent } from "../../src/matrix"; import { logger } from "../../src/logger"; import { TestClient } from "../TestClient"; import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; @@ -500,7 +500,8 @@ describe("MatrixClient event timelines", function() { Thread.setServerSideSupport(true); client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false); + const timelineSet = thread.timelineSet; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) .respond(200, function() { @@ -538,6 +539,92 @@ describe("MatrixClient event timelines", function() { expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy(); }); + + it("should return undefined when event is not within a thread but timelineSet is", async () => { + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const threadRoot = new MatrixEvent(THREAD_ROOT); + const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); + const timelineSet = thread.timelineSet; + + httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id)) + .respond(200, function() { + return THREAD_ROOT; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + + const timelinePromise = client.getEventTimeline(timelineSet, EVENTS[0].event_id); + await httpBackend.flushAllExpected(); + + const timeline = await timelinePromise; + expect(timeline).toBeUndefined(); + }); + + it("should return undefined when event is within a thread but timelineSet is not", async () => { + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: THREAD_REPLY, + events_after: [], + end: "end_token0", + state: [], + }; + }); + + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id); + await httpBackend.flushAllExpected(); + + const timeline = await timelinePromise; + expect(timeline).toBeUndefined(); + }); + + it("should should add lazy loading filter when requested", async () => { + client.clientOpts.lazyLoadMembers = true; + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)); + req.respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + req.check((request) => { + expect(request.opts.qs.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)); + }); + + await Promise.all([ + client.getEventTimeline(timelineSet, EVENTS[0].event_id), + httpBackend.flushAllExpected(), + ]); + }); }); describe("getLatestTimeline", function() { diff --git a/src/client.ts b/src/client.ts index 304655eac3b..c29578d20ac 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5245,14 +5245,15 @@ export class MatrixClient extends TypedEventEmitterIf the EventTimelineSet object already has the given event in its store, the * corresponding timeline will be returned. Otherwise, a /context request is * made, and used to construct an EventTimeline. + * If the event does not belong to this EventTimelineSet then undefined will be returned. * - * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in + * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in, must be bound to a room * @param {string} eventId The ID of the event to look for * * @return {Promise} Resolves: * {@link module:models/event-timeline~EventTimeline} including the given event */ - public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { + public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + @@ -5297,38 +5298,44 @@ export class MatrixClient extends TypedEventEmitter { this.timelineSet = new EventTimelineSet(this.room, { timelineSupport: true, pendingEvents: true, - }); + }, this.client, this); this.reEmitter = new TypedReEmitter(this); this.reEmitter.reEmit(this.timelineSet, [ From 8eeefc72e98b674e5fc83f8214155418cb393226 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 10 Jun 2022 17:29:48 +0100 Subject: [PATCH 23/41] Update pull_request.yaml (#2449) --- .github/workflows/pull_request.yaml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 59aa4557665..6cb9368886e 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -7,8 +7,8 @@ on: labels: type: string default: "T-Defect,T-Deprecation,T-Enhancement,T-Task" - required: true - description: "One of these provided labels must be present" + required: false + description: "No longer used, uses allchange logic now, will be removed at a later date" secrets: ELEMENT_BOT_TOKEN: required: true @@ -22,16 +22,7 @@ jobs: - uses: matrix-org/allchange@main with: ghToken: ${{ secrets.GITHUB_TOKEN }} - - enforce-label: - name: Enforce Labels - runs-on: ubuntu-latest - permissions: - pull-requests: read - steps: - - uses: yogevbd/enforce-label-action@2.2.2 - with: - REQUIRED_LABELS_ANY: ${{ inputs.labels }} + requireLabel: true prevent-blocked: name: Prevent Blocked From aaf508e30941fa118041eb46a8412e93aa890e90 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 11 Jun 2022 22:02:30 +0100 Subject: [PATCH 24/41] Update sonarcloud.yml (#2452) --- .github/workflows/sonarcloud.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecf6fb9381f..e9d965f02a0 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,7 @@ jobs: if: github.event.workflow_run.conclusion == 'success' steps: - name: "🩻 SonarCloud Scan" - uses: matrix-org/sonarcloud-workflow-action@v2.1 + uses: matrix-org/sonarcloud-workflow-action@v2.2 with: repository: ${{ github.event.workflow_run.head_repository.full_name }} is_pr: ${{ github.event.workflow_run.event == 'pull_request' }} From 4897bccdc9b2d3dad4209730d3846aa372a8bc87 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 13 Jun 2022 13:26:01 +0100 Subject: [PATCH 25/41] Improve decryption failure logging (#2453) * Improve typing * Log the actual errors to include call stacks --- src/crypto/OlmDevice.ts | 9 +++++++-- src/crypto/algorithms/megolm.ts | 4 ++-- src/crypto/algorithms/olm.ts | 3 ++- src/crypto/olmlib.ts | 2 +- src/models/event.ts | 9 ++------- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts index 2de8313d997..0b2e616a890 100644 --- a/src/crypto/OlmDevice.ts +++ b/src/crypto/OlmDevice.ts @@ -92,7 +92,7 @@ export interface InboundGroupSessionData { sharedHistory?: boolean; } -interface IDecryptedGroupMessage { +export interface IDecryptedGroupMessage { result: string; keysClaimed: Record; senderKey: string; @@ -100,6 +100,11 @@ interface IDecryptedGroupMessage { untrusted: boolean; } +export interface IInboundSession { + payload: string; + session_id: string; +} + export interface IExportedDevice { pickleKey: string; pickledAccount: string; @@ -620,7 +625,7 @@ export class OlmDevice { theirDeviceIdentityKey: string, messageType: number, ciphertext: string, - ): Promise<{ payload: string, session_id: string }> { // eslint-disable-line camelcase + ): Promise { if (messageType !== 0) { throw new Error("Need messageType == 0 to create inbound session"); } diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index 8bf5a7c629a..07636c11d7c 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -30,7 +30,7 @@ import { registerAlgorithm, UnknownDeviceError, } from "./base"; -import { WITHHELD_MESSAGES } from '../OlmDevice'; +import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from '../OlmDevice'; import { Room } from '../../models/room'; import { DeviceInfo } from "../deviceinfo"; import { IOlmSessionResult } from "../olmlib"; @@ -1280,7 +1280,7 @@ class MegolmDecryption extends DecryptionAlgorithm { // (fixes https://github.com/vector-im/element-web/issues/5001) this.addEventToPendingList(event); - let res; + let res: IDecryptedGroupMessage; try { res = await this.olmDevice.decryptGroupMessage( event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index c640d14efa1..e11a666e1f0 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -32,6 +32,7 @@ import { import { Room } from '../../models/room'; import { MatrixEvent } from "../.."; import { IEventDecryptionResult } from "../index"; +import { IInboundSession } from "../OlmDevice"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -331,7 +332,7 @@ class OlmDecryption extends DecryptionAlgorithm { // prekey message which doesn't match any existing sessions: make a new // session. - let res; + let res: IInboundSession; try { res = await this.olmDevice.createInboundSession( theirDeviceIdentityKey, message.type, message.body, diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index 19c34fe2b55..c017af08160 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -323,7 +323,7 @@ export async function ensureOlmSessionsForDevices( } const oneTimeKeyAlgorithm = "signed_curve25519"; - let res; + let res: IClaimOTKsResult; let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; try { log.debug(`Claiming ${taskDetail}`); diff --git a/src/models/event.ts b/src/models/event.ts index 79e17d711da..0ce12e0688c 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -823,18 +823,13 @@ export class MatrixEvent extends TypedEventEmitter Date: Mon, 13 Jun 2022 21:05:03 +0200 Subject: [PATCH 26/41] Various changes to `src/crypto` files for correctness (#2137) * make various changes for correctness * apply some review feedback * Address some review feedback * add some more correctness * refactor ensureOutboundSession to fit types better * change variable naming slightly to prevent confusion * some wording around exception-catching * Tidy test * Simplify * Add tests * Add more test coverage * Apply suggestions from code review Co-authored-by: Travis Ralston * Update crypto.spec.js * Update spec/unit/crypto.spec.js Co-authored-by: Faye Duxovni Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Travis Ralston Co-authored-by: Faye Duxovni --- spec/unit/crypto.spec.js | 296 +++++++------ spec/unit/crypto/algorithms/megolm.spec.js | 29 +- src/crypto/algorithms/base.ts | 2 +- src/crypto/algorithms/megolm.ts | 489 +++++++++++---------- src/crypto/algorithms/olm.ts | 8 +- src/crypto/index.ts | 2 +- src/crypto/olmlib.ts | 2 +- 7 files changed, 460 insertions(+), 368 deletions(-) diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index 69a074d25fd..bce8a9d3bc2 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -17,6 +17,43 @@ import { logger } from '../../src/logger'; const Olm = global.Olm; +function awaitEvent(emitter, event) { + return new Promise((resolve, reject) => { + emitter.once(event, (result) => { + resolve(result); + }); + }); +} + +async function keyshareEventForEvent(client, event, index) { + const roomId = event.getRoomId(); + const eventContent = event.getWireContent(); + const key = await client.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + eventContent.sender_key, + eventContent.session_id, + index, + ); + const ksEvent = new MatrixEvent({ + type: "m.forwarded_room_key", + sender: client.getUserId(), + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: eventContent.sender_key, + sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, + session_id: eventContent.session_id, + session_key: key.key, + chain_index: key.chain_index, + forwarding_curve25519_key_chain: + key.forwarding_curve_key_chain, + }, + }); + // make onRoomKeyEvent think this was an encrypted event + ksEvent.senderCurve25519Key = "akey"; + return ksEvent; +} + describe("Crypto", function() { if (!CRYPTO_ENABLED) { return; @@ -203,136 +240,141 @@ describe("Crypto", function() { bobClient.stopClient(); }); - it( - "does not cancel keyshare requests if some messages are not decrypted", - async function() { - function awaitEvent(emitter, event) { - return new Promise((resolve, reject) => { - emitter.once(event, (result) => { - resolve(result); - }); - }); + it("does not cancel keyshare requests if some messages are not decrypted", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + event.clearEvent = undefined; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet } + })); - async function keyshareEventForEvent(event, index) { - const eventContent = event.getWireContent(); - const key = await aliceClient.crypto.olmDevice - .getInboundGroupSessionKey( - roomId, eventContent.sender_key, eventContent.session_id, - index, - ); - const ksEvent = new MatrixEvent({ - type: "m.forwarded_room_key", - sender: "@alice:example.com", - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: eventContent.sender_key, - sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, - session_id: eventContent.session_id, - session_key: key.key, - chain_index: key.chain_index, - forwarding_curve25519_key_chain: - key.forwarding_curve_key_chain, - }, - }); - // make onRoomKeyEvent think this was an encrypted event - ksEvent.senderCurve25519Key = "akey"; - return ksEvent; - } + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); - const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all(events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto.encryptEvent(event, aliceRoom); - event.clearEvent = undefined; - event.senderCurve25519Key = null; - event.claimedEd25519Key = null; - try { - await bobClient.crypto.decryptEvent(event); - } catch (e) { - // we expect this to fail because we don't have the - // decryption keys yet - } - })); - - const bobDecryptor = bobClient.crypto.getRoomDecryptor( - roomId, olmlib.MEGOLM_ALGORITHM, - ); - - let eventPromise = Promise.all(events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - })); - - // keyshare the session key starting at the second message, so - // the first message can't be decrypted yet, but the second one - // can - let ksEvent = await keyshareEventForEvent(events[1], 1); - await bobDecryptor.onRoomKeyEvent(ksEvent); - await eventPromise; - expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); - expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); - - const cryptoStore = bobClient.cryptoStore; - const eventContent = events[0].getWireContent(); - const senderKey = eventContent.sender_key; - const sessionId = eventContent.session_id; - const roomKeyRequestBody = { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: senderKey, - session_id: sessionId, - }; - // the room key request should still be there, since we haven't - // decrypted everything - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) - .toBeDefined(); - - // keyshare the session key starting at the first message, so - // that it can now be decrypted - eventPromise = awaitEvent(events[0], "Event.decrypted"); - ksEvent = await keyshareEventForEvent(events[0], 0); - await bobDecryptor.onRoomKeyEvent(ksEvent); - await eventPromise; - expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); - await sleep(1); - // the room key request should be gone since we've now decrypted everything - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) - .toBeFalsy(); - }, - ); + let eventPromise = Promise.all(events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + })); + + // keyshare the session key starting at the second message, so + // the first message can't be decrypted yet, but the second one + // can + let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1); + await bobDecryptor.onRoomKeyEvent(ksEvent); + await eventPromise; + expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); + expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); + + const cryptoStore = bobClient.cryptoStore; + const eventContent = events[0].getWireContent(); + const senderKey = eventContent.sender_key; + const sessionId = eventContent.session_id; + const roomKeyRequestBody = { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: senderKey, + session_id: sessionId, + }; + // the room key request should still be there, since we haven't + // decrypted everything + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); + + // keyshare the session key starting at the first message, so + // that it can now be decrypted + eventPromise = awaitEvent(events[0], "Event.decrypted"); + ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + await bobDecryptor.onRoomKeyEvent(ksEvent); + await eventPromise; + expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + await sleep(1); + // the room key request should be gone since we've now decrypted everything + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy(); + }); + + it("should error if a forwarded room key lacks a content.sender_key", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const event = new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }); + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + event.clearEvent = undefined; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const ksEvent = await keyshareEventForEvent(aliceClient, event, 1); + ksEvent.getContent().sender_key = undefined; // test + bobClient.crypto.addInboundGroupSession = jest.fn(); + await bobDecryptor.onRoomKeyEvent(ksEvent); + expect(bobClient.crypto.addInboundGroupSession).not.toHaveBeenCalled(); + }); it("creates a new keyshare request if we request a keyshare", async function() { // make sure that cancelAndResend... creates a new keyshare request diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index de8894210ad..aa603b04954 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -257,6 +257,8 @@ describe("MegolmDecryption", function() { }); describe("session reuse and key reshares", () => { + const rotationPeriodMs = 999 * 24 * 60 * 60 * 1000; // 999 days, so we don't have to deal with it + let megolmEncryption; let aliceDeviceInfo; let mockRoom; @@ -318,7 +320,7 @@ describe("MegolmDecryption", function() { baseApis: mockBaseApis, roomId: ROOM_ID, config: { - rotation_period_ms: 9999999999999, + rotation_period_ms: rotationPeriodMs, }, }); mockRoom = { @@ -329,6 +331,31 @@ describe("MegolmDecryption", function() { }; }); + it("should use larger otkTimeout when preparing to encrypt room", async () => { + megolmEncryption.prepareToEncrypt(mockRoom); + await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); + + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 10000, + ); + }); + + it("should generate a new session if this one needs rotation", async () => { + const session = await megolmEncryption.prepareNewSession(false); + session.creationTime -= rotationPeriodMs + 10000; // a smidge over the rotation time + // Inject expired session which needs rotation + megolmEncryption.setupPromise = Promise.resolve(session); + + const prepareNewSessionSpy = jest.spyOn(megolmEncryption, "prepareNewSession"); + await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(prepareNewSessionSpy).toHaveBeenCalledTimes(1); + }); + it("re-uses sessions for sequential messages", async function() { const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { body: "Some text", diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index add9111efe0..22bd4505d57 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -179,7 +179,7 @@ export abstract class DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} params event key event */ - public onRoomKeyEvent(params: MatrixEvent): void { + public async onRoomKeyEvent(params: MatrixEvent): Promise { // ignore by default } diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index 07636c11d7c..f1ab0ee7178 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -213,6 +213,8 @@ class OutboundSessionInfo { } } } + + return false; } } @@ -231,7 +233,7 @@ class MegolmEncryption extends EncryptionAlgorithm { // are using, and which devices we have shared the keys with. It resolves // with an OutboundSessionInfo (or undefined, for the first message in the // room). - private setupPromise = Promise.resolve(undefined); + private setupPromise = Promise.resolve(null); // Map of outbound sessions by sessions ID. Used if we need a particular // session (the session we're currently using to send is always obtained @@ -240,8 +242,8 @@ class MegolmEncryption extends EncryptionAlgorithm { private readonly sessionRotationPeriodMsgs: number; private readonly sessionRotationPeriodMs: number; - private encryptionPreparation: Promise; - private encryptionPreparationMetadata: { + private encryptionPreparation?: { + promise: Promise; startTime: number; }; @@ -270,193 +272,209 @@ class MegolmEncryption extends EncryptionAlgorithm { blocked: IBlockedMap, singleOlmCreationPhase = false, ): Promise { - let session: OutboundSessionInfo; - // takes the previous OutboundSessionInfo, and considers whether to create // a new one. Also shares the key with any (new) devices in the room. - // Updates `session` to hold the final OutboundSessionInfo. + // + // Returns the successful session whether keyshare succeeds or not. // // returns a promise which resolves once the keyshare is successful. - const prepareSession = async (oldSession: OutboundSessionInfo) => { - session = oldSession; - + const setup = async (oldSession: OutboundSessionInfo | null): Promise => { const sharedHistory = isRoomSharedHistory(room); - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; - } + const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); - // need to make a brand new session? - if (session && session.needsRotation(this.sessionRotationPeriodMsgs, - this.sessionRotationPeriodMs) - ) { - logger.log("Starting new megolm session because we need to rotate."); - session = null; + try { + await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); + } catch (e) { + logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); } - // determine if we have shared with anyone we shouldn't have - if (session && session.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } + return session; + }; - if (!session) { - logger.log(`Starting new megolm session for room ${this.roomId}`); - session = await this.prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this.roomId}`); - this.outboundSessions[session.sessionId] = session; - } + // first wait for the previous share to complete + const prom = this.setupPromise.then(setup); - // now check if we need to share with any devices - const shareMap: Record = {}; + // Ensure any failures are logged for debugging + prom.catch(e => { + logger.error(`Failed to setup outbound session in ${this.roomId}`, e); + }); - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { - const key = deviceInfo.getIdentityKey(); - if (key == this.olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } + // setupPromise resolves to `session` whether or not the share succeeds + this.setupPromise = prom; - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } + // but we return a promise which only resolves if the share was successful. + return prom; + } - const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload: IPayload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this.roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this.olmDevice, this.baseApis, shareMap, - ); + private async prepareSession( + devicesInRoom: DeviceInfoMap, + sharedHistory: boolean, + session: OutboundSessionInfo | null, + ): Promise { + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } - await Promise.all([ - (async () => { - // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions); - await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); - logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); - })(), - (async () => { - logger.debug( - `Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`, - devicesWithoutSession, - ); - const errorDevices: IOlmDevice[] = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers: string[] = []; - await this.shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, - ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); - - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async () => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices: Record = {}; - const failedServerMap = new Set; - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices[userId] = retryDevices[userId] || []; - retryDevices[userId].push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } + // need to make a brand new session? + if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { + logger.log("Starting new megolm session because we need to rotate."); + session = null; + } - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); - await this.shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + // determine if we have shared with anyone we shouldn't have + if (session?.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } - await this.notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this.notifyFailedOlmDevices(session, key, errorDevices); - } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); - })(), - (async () => { - logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, - Object.entries(blocked)); - - // also, notify newly blocked devices that they're blocked - logger.debug(`Notifying newly blocked devices in ${this.roomId}`); - const blockedMap: Record> = {}; - let blockedCount = 0; - for (const [userId, userBlockedDevices] of Object.entries(blocked)) { - for (const [deviceId, device] of Object.entries(userBlockedDevices)) { - if ( - !session.blockedDevicesNotified[userId] || - session.blockedDevicesNotified[userId][deviceId] === undefined - ) { - blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = { device }; - blockedCount++; - } - } - } + if (!session) { + logger.log(`Starting new megolm session for room ${this.roomId}`); + session = await this.prepareNewSession(sharedHistory); + logger.log(`Started new megolm session ${session.sessionId} ` + + `for room ${this.roomId}`); + this.outboundSessions[session.sessionId] = session; + } - await this.notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap); - })(), - ]); - }; + return session; + } - // helper which returns the session prepared by prepareSession - function returnSession() { - return session; + private async shareSession( + devicesInRoom: DeviceInfoMap, + sharedHistory: boolean, + singleOlmCreationPhase: boolean, + blocked: IBlockedMap, + session: OutboundSessionInfo, + ) { + // now check if we need to share with any devices + const shareMap: Record = {}; + + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if ( + !session.sharedWithDevices[userId] || + session.sharedWithDevices[userId][deviceId] === undefined + ) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } } - // first wait for the previous share to complete - const prom = this.setupPromise.then(prepareSession); + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, this.baseApis, shareMap, + ); - // Ensure any failures are logged for debugging - prom.catch(e => { - logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); - }); + await Promise.all([ + (async () => { + // share keys with devices that we already have a session for + logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug( + `Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`, + devicesWithoutSession, + ); + const errorDevices: IOlmDevice[] = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers: string[] = []; + await this.shareKeyWithDevices( + session, key, payload, devicesWithoutSession, errorDevices, + singleOlmCreationPhase ? 10000 : 2000, failedServers, + ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + + if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices: Record = {}; + const failedServerMap = new Set; + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices: IOlmDevice[] = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices[userId] = retryDevices[userId] || []; + retryDevices[userId].push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } - // setupPromise resolves to `session` whether or not the share succeeds - this.setupPromise = prom.then(returnSession, returnSession); + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); + await this.shareKeyWithDevices( + session, key, payload, retryDevices, failedDevices, 30000, + ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); - // but we return a promise which only resolves if the share was successful. - return prom.then(returnSession); + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, + Object.entries(blocked)); + + // also, notify newly blocked devices that they're blocked + logger.debug(`Notifying newly blocked devices in ${this.roomId}`); + const blockedMap: Record> = {}; + let blockedCount = 0; + for (const [userId, userBlockedDevices] of Object.entries(blocked)) { + for (const [deviceId, device] of Object.entries(userBlockedDevices)) { + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || {}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; + } + } + } + + await this.notifyBlockedDevices(session, blockedMap); + logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap); + })(), + ]); } /** @@ -866,7 +884,7 @@ class MegolmEncryption extends EncryptionAlgorithm { logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, - logger.withPrefix(`[${this.roomId}]`), + logger.withPrefix?.(`[${this.roomId}]`), ); logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); @@ -1006,11 +1024,11 @@ class MegolmEncryption extends EncryptionAlgorithm { * @param {module:models/room} room the room the event is in */ public prepareToEncrypt(room: Room): void { - if (this.encryptionPreparation) { + if (this.encryptionPreparation != null) { // We're already preparing something, so don't do anything else. // FIXME: check if we need to restart // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; + const elapsedTime = Date.now() - this.encryptionPreparation.startTime; logger.debug( `Already started preparing to encrypt for ${this.roomId} ` + `${elapsedTime} ms ago, skipping`, @@ -1020,32 +1038,31 @@ class MegolmEncryption extends EncryptionAlgorithm { logger.debug(`Preparing to encrypt events for ${this.roomId}`); - this.encryptionPreparationMetadata = { + this.encryptionPreparation = { startTime: Date.now(), - }; - this.encryptionPreparation = (async () => { - try { - logger.debug(`Getting devices in ${this.roomId}`); - const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); - - if (this.crypto.getGlobalErrorOnUnknownDevices()) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this.removeUnknownDevices(devicesInRoom); - } + promise: (async () => { + try { + logger.debug(`Getting devices in ${this.roomId}`); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } - logger.debug(`Ensuring outbound session in ${this.roomId}`); - await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + logger.debug(`Ensuring outbound session in ${this.roomId}`); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); - logger.debug(`Ready to encrypt events for ${this.roomId}`); - } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); - } finally { - delete this.encryptionPreparationMetadata; - delete this.encryptionPreparation; - } - })(); + logger.debug(`Ready to encrypt events for ${this.roomId}`); + } catch (e) { + logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + } finally { + delete this.encryptionPreparation; + } + })(), + }; } /** @@ -1060,12 +1077,12 @@ class MegolmEncryption extends EncryptionAlgorithm { public async encryptMessage(room: Room, eventType: string, content: object): Promise { logger.log(`Starting to encrypt event for ${this.roomId}`); - if (this.encryptionPreparation) { + if (this.encryptionPreparation != null) { // If we started sending keys, wait for it to be done. // FIXME: check if we need to cancel // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) try { - await this.encryptionPreparation; + await this.encryptionPreparation.promise; } catch (e) { // ignore any errors -- if the preparation failed, we'll just // restart everything here @@ -1405,7 +1422,7 @@ class MegolmDecryption extends DecryptionAlgorithm { if (!senderPendingEvents.has(sessionId)) { senderPendingEvents.set(sessionId, new Set()); } - senderPendingEvents.get(sessionId).add(event); + senderPendingEvents.get(sessionId)?.add(event); } /** @@ -1439,17 +1456,17 @@ class MegolmDecryption extends DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} event key event */ - public onRoomKeyEvent(event: MatrixEvent): Promise { - const content = event.getContent(); - const sessionId = content.session_id; + public async onRoomKeyEvent(event: MatrixEvent): Promise { + const content = event.getContent>(); let senderKey = event.getSenderKey(); - let forwardingKeyChain = []; + let forwardingKeyChain: string[] = []; let exportFormat = false; - let keysClaimed; + let keysClaimed: ReturnType; if (!content.room_id || - !sessionId || - !content.session_key + !content.session_key || + !content.session_id || + !content.algorithm ) { logger.error("key event is missing fields"); return; @@ -1462,20 +1479,18 @@ class MegolmDecryption extends DecryptionAlgorithm { if (event.getType() == "m.forwarded_room_key") { exportFormat = true; - forwardingKeyChain = content.forwarding_curve25519_key_chain; - if (!Array.isArray(forwardingKeyChain)) { - forwardingKeyChain = []; - } + forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ? + content.forwarding_curve25519_key_chain : []; // copy content before we modify it forwardingKeyChain = forwardingKeyChain.slice(); forwardingKeyChain.push(senderKey); - senderKey = content.sender_key; - if (!senderKey) { + if (!content.sender_key) { logger.error("forwarded_room_key event is missing sender_key field"); return; } + senderKey = content.sender_key; const ed25519Key = content.sender_claimed_ed25519_key; if (!ed25519Key) { @@ -1496,34 +1511,39 @@ class MegolmDecryption extends DecryptionAlgorithm { if (content["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } - return this.olmDevice.addInboundGroupSession( - content.room_id, senderKey, forwardingKeyChain, sessionId, - content.session_key, keysClaimed, - exportFormat, extraSessionData, - ).then(() => { + + try { + await this.olmDevice.addInboundGroupSession( + content.room_id, + senderKey, + forwardingKeyChain, + content.session_id, + content.session_key, + keysClaimed, + exportFormat, + extraSessionData, + ); + // have another go at decrypting events sent with this session. - this.retryDecryption(senderKey, sessionId) - .then((success) => { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - if (success) { - this.crypto.cancelRoomKeyRequest({ - algorithm: content.algorithm, - room_id: content.room_id, - session_id: content.session_id, - sender_key: senderKey, - }); - } + if (await this.retryDecryption(senderKey, content.session_id)) { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + this.crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, }); - }).then(() => { + } + // don't wait for the keys to be backed up for the server - this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); - }).catch((e) => { + await this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); + } catch (e) { logger.error(`Error handling m.room_key_event: ${e}`); - }); + } } /** @@ -1716,7 +1736,10 @@ class MegolmDecryption extends DecryptionAlgorithm { * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted * @param {string} [opts.source] where the key came from */ - public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise { + public importRoomKey( + session: IMegolmSessionData, + opts: { untrusted?: boolean, source?: string } = {}, + ): Promise { const extraSessionData: any = {}; if (opts.untrusted || session.untrusted) { extraSessionData.untrusted = true; diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index e11a666e1f0..aec39d49e6e 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -52,7 +52,7 @@ interface IMessage { */ class OlmEncryption extends EncryptionAlgorithm { private sessionPrepared = false; - private prepPromise: Promise = null; + private prepPromise: Promise | null = null; /** * @private @@ -117,11 +117,11 @@ class OlmEncryption extends EncryptionAlgorithm { ciphertext: {}, }; - const promises = []; + const promises: Promise[] = []; for (let i = 0; i < users.length; ++i) { const userId = users[i]; - const devices = this.crypto.getStoredDevicesForUser(userId); + const devices = this.crypto.getStoredDevicesForUser(userId) || []; for (let j = 0; j < devices.length; ++j) { const deviceInfo = devices[j]; @@ -240,7 +240,7 @@ class OlmDecryption extends DecryptionAlgorithm { throw new DecryptionError( "OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { - reported_room: event.getRoomId(), + reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED", }, ); } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 4add71f978e..a6f27751343 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -2590,7 +2590,7 @@ export class Crypto extends TypedEventEmitter = null; if (!existingConfig) { storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); } diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index c017af08160..9d4478449b3 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -76,7 +76,7 @@ export interface IOlmSessionResult { export async function encryptMessageForDevice( resultsObject: Record, ourUserId: string, - ourDeviceId: string, + ourDeviceId: string | undefined, olmDevice: OlmDevice, recipientUserId: string, recipientDevice: DeviceInfo, From eb8491c91bbc5662fef9406d4d8953c1ab47f19d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Jun 2022 11:30:57 +0100 Subject: [PATCH 27/41] Skip running jobs on fork `develop` where they lack secrets (#2460) * Skip running jobs on fork `develop` where they lack secrets * Fix contexts --- .github/workflows/notify-downstream.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/notify-downstream.yaml b/.github/workflows/notify-downstream.yaml index d2318b05f6e..2de50fd8b10 100644 --- a/.github/workflows/notify-downstream.yaml +++ b/.github/workflows/notify-downstream.yaml @@ -5,6 +5,8 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: notify-downstream: + # Only respect triggers from our develop branch, ignore that of forks + if: github.repository == 'matrix-org/matrix-js-sdk' continue-on-error: true strategy: fail-fast: false From b9ca3ceacd75b7ecd9aa70305bd3dbf78ee05546 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 14 Jun 2022 21:29:21 +0100 Subject: [PATCH 28/41] Remove unused sessionStore (#2455) * Remove unused sessionStorage layer * Move pending event abstraction into its temporary home * Add test coverage * Tweak * Fix tests mocks * Add coverage * Add coverage --- spec/TestClient.ts | 3 - spec/unit/crypto.spec.js | 7 +- spec/unit/crypto/backup.spec.js | 15 +- spec/unit/room.spec.ts | 2 + spec/unit/stores/indexeddb.spec.ts | 46 +++- src/client.ts | 20 +- src/crypto/index.ts | 12 - src/crypto/store/indexeddb-crypto-store.ts | 2 +- src/matrix.ts | 1 - src/models/room.ts | 40 +--- src/store/index.ts | 6 +- src/store/indexeddb.ts | 34 +++ src/store/memory.ts | 15 +- src/store/session/webstorage.js | 263 --------------------- src/store/stub.ts | 10 +- 15 files changed, 128 insertions(+), 348 deletions(-) delete mode 100644 src/store/session/webstorage.js diff --git a/spec/TestClient.ts b/spec/TestClient.ts index dc3d556b0e0..244a9d6e3a3 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -23,7 +23,6 @@ import MockHttpBackend from 'matrix-mock-request'; import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; import { logger } from '../src/logger'; -import { WebStorageSessionStore } from "../src/store/session/webstorage"; import { syncPromise } from "./test-utils/test-utils"; import { createClient } from "../src/matrix"; import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client"; @@ -53,7 +52,6 @@ export class TestClient { if (sessionStoreBackend === undefined) { sessionStoreBackend = new MockStorageApi(); } - const sessionStore = new WebStorageSessionStore(sessionStoreBackend); this.httpBackend = new MockHttpBackend(); @@ -62,7 +60,6 @@ export class TestClient { userId: userId, accessToken: accessToken, deviceId: deviceId, - sessionStore: sessionStore, request: this.httpBackend.requestFn as IHttpOpts["request"], ...options, }; diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index bce8a9d3bc2..ba74eb51779 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -3,7 +3,6 @@ import '../olm-loader'; import { EventEmitter } from "events"; import { Crypto } from "../../src/crypto"; -import { WebStorageSessionStore } from "../../src/store/session/webstorage"; import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store"; import { MockStorageApi } from "../MockStorageApi"; import { TestClient } from "../TestClient"; @@ -14,6 +13,7 @@ import { sleep } from "../../src/utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { logger } from '../../src/logger'; +import { MemoryStore } from "../../src"; const Olm = global.Olm; @@ -153,7 +153,7 @@ describe("Crypto", function() { beforeEach(async function() { const mockStorage = new MockStorageApi(); - const sessionStore = new WebStorageSessionStore(mockStorage); + const clientStore = new MemoryStore({ localStorage: mockStorage }); const cryptoStore = new MemoryCryptoStore(mockStorage); cryptoStore.storeEndToEndDeviceData({ @@ -180,10 +180,9 @@ describe("Crypto", function() { crypto = new Crypto( mockBaseApis, - sessionStore, "@alice:home.server", "FLIBBLE", - sessionStore, + clientStore, cryptoStore, mockRoomList, ); diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index 9f435dc91f9..cab0c0d0d9f 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -21,7 +21,6 @@ import * as olmlib from "../../../src/crypto/olmlib"; import { MatrixClient } from "../../../src/client"; import { MatrixEvent } from "../../../src/models/event"; import * as algorithms from "../../../src/crypto/algorithms"; -import { WebStorageSessionStore } from "../../../src/store/session/webstorage"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; import { MockStorageApi } from "../../MockStorageApi"; import * as testUtils from "../../test-utils/test-utils"; @@ -118,7 +117,7 @@ function saveCrossSigningKeys(k) { Object.assign(keys, k); } -function makeTestClient(sessionStore, cryptoStore) { +function makeTestClient(cryptoStore) { const scheduler = [ "getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction", @@ -141,7 +140,6 @@ function makeTestClient(sessionStore, cryptoStore) { scheduler: scheduler, userId: "@alice:bar", deviceId: "device", - sessionStore: sessionStore, cryptoStore: cryptoStore, cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys }, }); @@ -161,7 +159,6 @@ describe("MegolmBackup", function() { let mockOlmLib; let mockCrypto; let mockStorage; - let sessionStore; let cryptoStore; let megolmDecryption; beforeEach(async function() { @@ -174,7 +171,6 @@ describe("MegolmBackup", function() { mockCrypto.backupInfo = CURVE25519_BACKUP_INFO; mockStorage = new MockStorageApi(); - sessionStore = new WebStorageSessionStore(mockStorage); cryptoStore = new MemoryCryptoStore(mockStorage); olmDevice = new OlmDevice(cryptoStore); @@ -261,7 +257,7 @@ describe("MegolmBackup", function() { const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const client = makeTestClient(sessionStore, cryptoStore); + const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', @@ -340,7 +336,7 @@ describe("MegolmBackup", function() { const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const client = makeTestClient(sessionStore, cryptoStore); + const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', @@ -423,7 +419,7 @@ describe("MegolmBackup", function() { const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const client = makeTestClient(sessionStore, cryptoStore); + const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', @@ -520,7 +516,6 @@ describe("MegolmBackup", function() { scheduler: scheduler, userId: "@alice:bar", deviceId: "device", - sessionStore: sessionStore, cryptoStore: cryptoStore, }); @@ -606,7 +601,7 @@ describe("MegolmBackup", function() { let client; beforeEach(function() { - client = makeTestClient(sessionStore, cryptoStore); + client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 3807795d1f3..cd6aa477556 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -1555,6 +1555,8 @@ describe("Room", function() { return Promise.resolve(); }, getSyncToken: () => "sync_token", + getPendingEvents: jest.fn().mockResolvedValue([]), + setPendingEvents: jest.fn().mockResolvedValue(undefined), }, }; } diff --git a/spec/unit/stores/indexeddb.spec.ts b/spec/unit/stores/indexeddb.spec.ts index 06e3097ea52..3fc7477cca2 100644 --- a/spec/unit/stores/indexeddb.spec.ts +++ b/spec/unit/stores/indexeddb.spec.ts @@ -17,13 +17,17 @@ limitations under the License. import 'fake-indexeddb/auto'; import 'jest-localstorage-mock'; -import { IndexedDBStore, IStateEventWithRoomId } from "../../../src"; +import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src"; import { emitPromise } from "../../test-utils/test-utils"; import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend"; describe("IndexedDBStore", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const roomId = "!room:id"; it("should degrade to MemoryStore on IDB errors", async () => { - const roomId = "!room:id"; const store = new IndexedDBStore({ indexedDB: indexedDB, dbName: "database", @@ -69,4 +73,42 @@ describe("IndexedDBStore", () => { ]); expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2); }); + + it("should use MemoryStore methods for pending events if no localStorage", async () => { + jest.spyOn(MemoryStore.prototype, "setPendingEvents"); + jest.spyOn(MemoryStore.prototype, "getPendingEvents"); + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage: undefined, + }); + + const events = [{ type: "test" }]; + await store.setPendingEvents(roomId, events); + expect(MemoryStore.prototype.setPendingEvents).toHaveBeenCalledWith(roomId, events); + await expect(store.getPendingEvents(roomId)).resolves.toEqual(events); + expect(MemoryStore.prototype.getPendingEvents).toHaveBeenCalledWith(roomId); + }); + + it("should persist pending events to localStorage if available", async () => { + jest.spyOn(MemoryStore.prototype, "setPendingEvents"); + jest.spyOn(MemoryStore.prototype, "getPendingEvents"); + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + }); + + await expect(store.getPendingEvents(roomId)).resolves.toEqual([]); + const events = [{ type: "test" }]; + await store.setPendingEvents(roomId, events); + expect(MemoryStore.prototype.setPendingEvents).not.toHaveBeenCalled(); + await expect(store.getPendingEvents(roomId)).resolves.toEqual(events); + expect(MemoryStore.prototype.getPendingEvents).not.toHaveBeenCalled(); + expect(localStorage.getItem("mx_pending_events_" + roomId)).toBe(JSON.stringify(events)); + await store.setPendingEvents(roomId, []); + expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull(); + }); }); diff --git a/src/client.ts b/src/client.ts index c29578d20ac..d8003a1f391 100644 --- a/src/client.ts +++ b/src/client.ts @@ -168,7 +168,6 @@ import { import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import { randomString } from "./randomstring"; -import { WebStorageSessionStore } from "./store/session/webstorage"; import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; import { ISignatures } from "./@types/signed"; @@ -195,7 +194,6 @@ import { Thread, THREAD_RELATION_TYPE } from "./models/thread"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; export type Store = IStore; -export type SessionStore = WebStorageSessionStore; export type Callback = (err: Error | any | null, data?: T) => void; export type ResetTimelineCallback = (roomId: string) => boolean; @@ -315,14 +313,6 @@ export interface ICreateClientOpts { */ pickleKey?: string; - /** - * A store to be used for end-to-end crypto session data. Most data has been - * migrated out of here to `cryptoStore` instead. If not specified, - * end-to-end crypto will be disabled. The `createClient` helper - * _will not_ create this store at the moment. - */ - sessionStore?: SessionStore; - verificationMethods?: Array; /** @@ -897,7 +887,6 @@ export class MatrixClient extends TypedEventEmitter } = {}; public identityServer: IIdentityServerProvider; - public sessionStore: SessionStore; // XXX: Intended private, used in code. public http: MatrixHttpApi; // XXX: Intended private, used in code. public crypto: Crypto; // XXX: Intended private, used in code. public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. @@ -1029,7 +1018,6 @@ export class MatrixClient extends TypedEventEmitter { - // This should be redundant post cross-signing is a thing, so just - // plonk it in localStorage for now. - this.sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); - await this.backupManager.checkKeyBackup(); - } - /** */ public enableLazyLoading(): void { diff --git a/src/crypto/store/indexeddb-crypto-store.ts b/src/crypto/store/indexeddb-crypto-store.ts index 0d355292882..ecc3d86c3cc 100644 --- a/src/crypto/store/indexeddb-crypto-store.ts +++ b/src/crypto/store/indexeddb-crypto-store.ts @@ -157,7 +157,7 @@ export class IndexedDBCryptoStore implements CryptoStore { } }).then(backend => { this.backend = backend; - return backend as CryptoStore; + return backend; }); return this.backendPromise; diff --git a/src/matrix.ts b/src/matrix.ts index e2ce5e11c9a..6813655a995 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -41,7 +41,6 @@ export * from "./interactive-auth"; export * from "./service-types"; export * from "./store/memory"; export * from "./store/indexeddb"; -export * from "./store/session/webstorage"; export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; diff --git a/src/models/room.ts b/src/models/room.ts index e2e09e4773b..3c8e5adcd0b 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -368,18 +368,16 @@ export class Room extends TypedEventEmitter if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) { this.pendingEventList = []; - const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId)); - if (serializedPendingEventList) { - JSON.parse(serializedPendingEventList) - .forEach(async (serializedEvent: Partial) => { - const event = new MatrixEvent(serializedEvent); - if (event.getType() === EventType.RoomMessageEncrypted) { - await event.attemptDecryption(this.client.crypto); - } - event.setStatus(EventStatus.NOT_SENT); - this.addPendingEvent(event, event.getTxnId()); - }); - } + this.client.store.getPendingEvents(this.roomId).then(events => { + events.forEach(async (serializedEvent: Partial) => { + const event = new MatrixEvent(serializedEvent); + if (event.getType() === EventType.RoomMessageEncrypted) { + await event.attemptDecryption(this.client.crypto); + } + event.setStatus(EventStatus.NOT_SENT); + this.addPendingEvent(event, event.getTxnId()); + }); + }); } // awaited by getEncryptionTargetMembers while room members are loading @@ -2075,15 +2073,7 @@ export class Room extends TypedEventEmitter return isEventEncrypted || !isRoomEncrypted; }); - const { store } = this.client.sessionStore; - if (this.pendingEventList.length > 0) { - store.setItem( - pendingEventsKey(this.roomId), - JSON.stringify(pendingEvents), - ); - } else { - store.removeItem(pendingEventsKey(this.roomId)); - } + this.client.store.setPendingEvents(this.roomId, pendingEvents); } } @@ -3112,14 +3102,6 @@ export class Room extends TypedEventEmitter } } -/** - * @param {string} roomId ID of the current room - * @returns {string} Storage key to retrieve pending events - */ -function pendingEventsKey(roomId: string): string { - return `mx_pending_events_${roomId}`; -} - // a map from current event status to a list of allowed next statuses const ALLOWED_TRANSITIONS: Record = { [EventStatus.ENCRYPTING]: [ diff --git a/src/store/index.ts b/src/store/index.ts index f71f7c093a5..3f4a0dadeb7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,7 +17,7 @@ limitations under the License. import { EventType } from "../@types/event"; import { Room } from "../models/room"; import { User } from "../models/user"; -import { MatrixEvent } from "../models/event"; +import { IEvent, MatrixEvent } from "../models/event"; import { Filter } from "../filter"; import { RoomSummary } from "../models/room-summary"; import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator"; @@ -218,4 +218,8 @@ export interface IStore { getClientOptions(): Promise; storeClientOptions(options: IStartClientOpts): Promise; + + getPendingEvents(roomId: string): Promise[]>; + + setPendingEvents(roomId: string, events: Partial[]): Promise; } diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 7d6e3f17c22..09a85fd1b54 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -325,6 +325,40 @@ export class IndexedDBStore extends MemoryStore { } }; } + + // XXX: ideally these would be stored in indexeddb as part of the room but, + // we don't store rooms as such and instead accumulate entire sync responses atm. + public async getPendingEvents(roomId: string): Promise[]> { + if (!this.localStorage) return super.getPendingEvents(roomId); + + const serialized = this.localStorage.getItem(pendingEventsKey(roomId)); + if (serialized) { + try { + return JSON.parse(serialized); + } catch (e) { + logger.error("Could not parse persisted pending events", e); + } + } + return []; + } + + public async setPendingEvents(roomId: string, events: Partial[]): Promise { + if (!this.localStorage) return super.setPendingEvents(roomId, events); + + if (events.length > 0) { + this.localStorage.setItem(pendingEventsKey(roomId), JSON.stringify(events)); + } else { + this.localStorage.removeItem(pendingEventsKey(roomId)); + } + } +} + +/** + * @param {string} roomId ID of the current room + * @returns {string} Storage key to retrieve pending events + */ +function pendingEventsKey(roomId: string): string { + return `mx_pending_events_${roomId}`; } type DegradableFn, T> = (...args: A) => Promise; diff --git a/src/store/memory.ts b/src/store/memory.ts index 4d59fc13854..cb49e425fdb 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -22,7 +22,7 @@ limitations under the License. import { EventType } from "../@types/event"; import { Room } from "../models/room"; import { User } from "../models/user"; -import { MatrixEvent } from "../models/event"; +import { IEvent, MatrixEvent } from "../models/event"; import { RoomState, RoomStateEvent } from "../models/room-state"; import { RoomMember } from "../models/room-member"; import { Filter } from "../filter"; @@ -48,7 +48,7 @@ export interface IOpts { * Construct a new in-memory data store for the Matrix Client. * @constructor * @param {Object=} opts Config options - * @param {LocalStorage} opts.localStorage The local storage instance to persist + * @param {Storage} opts.localStorage The local storage instance to persist * some forms of data such as tokens. Rooms will NOT be stored. */ export class MemoryStore implements IStore { @@ -60,8 +60,9 @@ export class MemoryStore implements IStore { // } private filters: Record> = {}; public accountData: Record = {}; // type : content - private readonly localStorage: Storage; + protected readonly localStorage: Storage; private oobMembers: Record = {}; // roomId: [member events] + private pendingEvents: { [roomId: string]: Partial[] } = {}; private clientOptions = {}; constructor(opts: IOpts = {}) { @@ -420,4 +421,12 @@ export class MemoryStore implements IStore { this.clientOptions = Object.assign({}, options); return Promise.resolve(); } + + public async getPendingEvents(roomId: string): Promise[]> { + return this.pendingEvents[roomId] ?? []; + } + + public async setPendingEvents(roomId: string, events: Partial[]): Promise { + this.pendingEvents[roomId] = events; + } } diff --git a/src/store/session/webstorage.js b/src/store/session/webstorage.js deleted file mode 100644 index f11bbe20798..00000000000 --- a/src/store/session/webstorage.js +++ /dev/null @@ -1,263 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * @module store/session/webstorage - */ - -import * as utils from "../../utils"; -import { logger } from '../../logger'; - -const DEBUG = false; // set true to enable console logging. -const E2E_PREFIX = "session.e2e."; - -/** - * Construct a web storage session store, capable of storing account keys, - * session keys and access tokens. - * @constructor - * @param {WebStorage} webStore A web storage implementation, e.g. - * 'window.localStorage' or 'window.sessionStorage' or a custom implementation. - * @throws if the supplied 'store' does not meet the Storage interface of the - * WebStorage API. - */ -export function WebStorageSessionStore(webStore) { - this.store = webStore; - if (!utils.isFunction(webStore.getItem) || - !utils.isFunction(webStore.setItem) || - !utils.isFunction(webStore.removeItem) || - !utils.isFunction(webStore.key) || - typeof(webStore.length) !== 'number' - ) { - throw new Error( - "Supplied webStore does not meet the WebStorage API interface", - ); - } -} - -WebStorageSessionStore.prototype = { - /** - * Remove the stored end to end account for the logged-in user. - */ - removeEndToEndAccount: function() { - this.store.removeItem(KEY_END_TO_END_ACCOUNT); - }, - - /** - * Load the end to end account for the logged-in user. - * Note that the end-to-end account is now stored in the - * crypto store rather than here: this remains here so - * old sessions can be migrated out of the session store. - * @return {?string} Base64 encoded account. - */ - getEndToEndAccount: function() { - return this.store.getItem(KEY_END_TO_END_ACCOUNT); - }, - - /** - * Retrieves the known devices for all users. - * @return {object} A map from user ID to map of device ID to keys for the device. - */ - getAllEndToEndDevices: function() { - const prefix = keyEndToEndDevicesForUser(''); - const devices = {}; - for (let i = 0; i < this.store.length; ++i) { - const key = this.store.key(i); - const userId = key.slice(prefix.length); - if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key); - } - return devices; - }, - - getEndToEndDeviceTrackingStatus: function() { - return getJsonItem(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS); - }, - - /** - * Get the sync token corresponding to the device list. - * - * @return {String?} token - */ - getEndToEndDeviceSyncToken: function() { - return getJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); - }, - - /** - * Removes all end to end device data from the store - */ - removeEndToEndDeviceData: function() { - removeByPrefix(this.store, keyEndToEndDevicesForUser('')); - removeByPrefix(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS); - removeByPrefix(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); - }, - - /** - * Retrieve the end-to-end sessions between the logged-in user and another - * device. - * @param {string} deviceKey The public key of the other device. - * @return {object} A map from sessionId to Base64 end-to-end session. - */ - getEndToEndSessions: function(deviceKey) { - return getJsonItem(this.store, keyEndToEndSessions(deviceKey)); - }, - - /** - * Retrieve all end-to-end sessions between the logged-in user and other - * devices. - * @return {object} A map of {deviceKey -> {sessionId -> session pickle}} - */ - getAllEndToEndSessions: function() { - const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions('')); - const results = {}; - for (const k of deviceKeys) { - const unprefixedKey = k.slice(keyEndToEndSessions('').length); - results[unprefixedKey] = getJsonItem(this.store, k); - } - return results; - }, - - /** - * Remove all end-to-end sessions from the store - * This is used after migrating sessions awat from the sessions store. - */ - removeAllEndToEndSessions: function() { - removeByPrefix(this.store, keyEndToEndSessions('')); - }, - - /** - * Retrieve a list of all known inbound group sessions - * - * @return {{senderKey: string, sessionId: string}} - */ - getAllEndToEndInboundGroupSessionKeys: function() { - const prefix = E2E_PREFIX + 'inboundgroupsessions/'; - const result = []; - for (let i = 0; i < this.store.length; i++) { - const key = this.store.key(i); - if (!key.startsWith(prefix)) { - continue; - } - // we can't use split, as the components we are trying to split out - // might themselves contain '/' characters. We rely on the - // senderKey being a (32-byte) curve25519 key, base64-encoded - // (hence 43 characters long). - - result.push({ - senderKey: key.slice(prefix.length, prefix.length + 43), - sessionId: key.slice(prefix.length + 44), - }); - } - return result; - }, - - getEndToEndInboundGroupSession: function(senderKey, sessionId) { - const key = keyEndToEndInboundGroupSession(senderKey, sessionId); - return this.store.getItem(key); - }, - - removeAllEndToEndInboundGroupSessions: function() { - removeByPrefix(this.store, E2E_PREFIX + 'inboundgroupsessions/'); - }, - - /** - * Get the end-to-end state for all rooms - * @return {object} roomId -> object with the end-to-end info for the room. - */ - getAllEndToEndRooms: function() { - const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom('')); - const results = {}; - for (const k of roomKeys) { - const unprefixedKey = k.slice(keyEndToEndRoom('').length); - results[unprefixedKey] = getJsonItem(this.store, k); - } - return results; - }, - - removeAllEndToEndRooms: function() { - removeByPrefix(this.store, keyEndToEndRoom('')); - }, - - setLocalTrustedBackupPubKey: function(pubkey) { - this.store.setItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY, pubkey); - }, - - // XXX: This store is deprecated really, but added this as a temporary - // thing until cross-signing lands. - getLocalTrustedBackupPubKey: function() { - return this.store.getItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY); - }, -}; - -const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; -const KEY_END_TO_END_DEVICE_SYNC_TOKEN = E2E_PREFIX + "device_sync_token"; -const KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS = E2E_PREFIX + "device_tracking"; -const KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY = E2E_PREFIX + "trusted_backup_pubkey"; - -function keyEndToEndDevicesForUser(userId) { - return E2E_PREFIX + "devices/" + userId; -} - -function keyEndToEndSessions(deviceKey) { - return E2E_PREFIX + "sessions/" + deviceKey; -} - -function keyEndToEndInboundGroupSession(senderKey, sessionId) { - return E2E_PREFIX + "inboundgroupsessions/" + senderKey + "/" + sessionId; -} - -function keyEndToEndRoom(roomId) { - return E2E_PREFIX + "rooms/" + roomId; -} - -function getJsonItem(store, key) { - try { - // if the key is absent, store.getItem() returns null, and - // JSON.parse(null) === null, so this returns null. - return JSON.parse(store.getItem(key)); - } catch (e) { - debuglog("Failed to get key %s: %s", key, e); - debuglog(e.stack); - } - return null; -} - -function getKeysWithPrefix(store, prefix) { - const results = []; - for (let i = 0; i < store.length; ++i) { - const key = store.key(i); - if (key.startsWith(prefix)) results.push(key); - } - return results; -} - -function removeByPrefix(store, prefix) { - const toRemove = []; - for (let i = 0; i < store.length; ++i) { - const key = store.key(i); - if (key.startsWith(prefix)) toRemove.push(key); - } - for (const key of toRemove) { - store.removeItem(key); - } -} - -function debuglog(...args) { - if (DEBUG) { - logger.log(...args); - } -} diff --git a/src/store/stub.ts b/src/store/stub.ts index 1b3a8773f4e..c9fc57055fd 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -22,7 +22,7 @@ limitations under the License. import { EventType } from "../@types/event"; import { Room } from "../models/room"; import { User } from "../models/user"; -import { MatrixEvent } from "../models/event"; +import { IEvent, MatrixEvent } from "../models/event"; import { Filter } from "../filter"; import { ISavedSync, IStore } from "./index"; import { RoomSummary } from "../models/room-summary"; @@ -262,4 +262,12 @@ export class StubStore implements IStore { public storeClientOptions(options: object): Promise { return Promise.resolve(); } + + public async getPendingEvents(roomId: string): Promise[]> { + return []; + } + + public setPendingEvents(roomId: string, events: Partial[]): Promise { + return Promise.resolve(); + } } From a1ab0d42fe131ef74cabd06d930f434fa533df80 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jun 2022 16:12:37 -0600 Subject: [PATCH 29/41] Clearly indicate that `lastReply` on a Thread can return falsy (#2462) --- src/models/thread.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/models/thread.ts b/src/models/thread.ts index 07ca21018fa..aa6c8ae38e7 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { Optional } from "matrix-events-sdk"; + import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix"; import { TypedReEmitter } from "../ReEmitter"; import { IRelationsRequestOpts } from "../@types/requests"; @@ -328,15 +330,16 @@ export class Thread extends TypedEventEmitter { } /** - * Return last reply to the thread + * Return last reply to the thread, if known. */ - public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): MatrixEvent { + public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): Optional { for (let i = this.events.length - 1; i >= 0; i--) { const event = this.events[i]; if (matches(event)) { return event; } } + return null; } public get roomId(): string { @@ -353,9 +356,9 @@ export class Thread extends TypedEventEmitter { } /** - * A getter for the last event added to the thread + * A getter for the last event added to the thread, if known. */ - public get replyToEvent(): MatrixEvent { + public get replyToEvent(): Optional { return this.lastEvent ?? this.lastReply(); } From d3ff7655f78e879eeeaa7e4fa951cf12011f9445 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 15 Jun 2022 00:37:02 -0600 Subject: [PATCH 30/41] Add missing `type` property on `IAuthData` (#2463) Per spec, for example: https://spec.matrix.org/v1.2/client-server-api/#dummy-auth --- src/interactive-auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interactive-auth.ts b/src/interactive-auth.ts index 38bbcb04493..d5e10427e51 100644 --- a/src/interactive-auth.ts +++ b/src/interactive-auth.ts @@ -44,6 +44,7 @@ export interface IStageStatus { export interface IAuthData { session?: string; + type?: string; completed?: string[]; flows?: IFlow[]; available_flows?: IFlow[]; From b43b4aa9f9b164440bbbb6a6e792b498680f7920 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Jun 2022 13:44:42 +0100 Subject: [PATCH 31/41] Log real errors and not just their messages, traces are useful (#2464) --- src/crypto/index.ts | 4 ++-- src/store/indexeddb-store-worker.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 6e37fafba34..6c3436854c4 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -3284,7 +3284,7 @@ export class Crypto extends TypedEventEmitter { - logger.error("Error running command: " + msg.command); - logger.error(err); + logger.error("Error running command: " + msg.command, err); this.postMessage.call(null, { command: 'cmd_fail', seq: msg.seq, From ab588f0e51bed8edf0ec97b1f4622a13112a1476 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Jun 2022 15:46:08 +0100 Subject: [PATCH 32/41] Fix issue with `getEventTimeline` returning undefined for thread roots in main timeline (#2454) * Fix test message utils using overload * Tweak existing tests * Add test around `MatrixClient::getEventTimeline` * Fix test to actually exercise the faulty behaviour * Extract timelineSet thread belongs logic and test it * tweak method name --- .../matrix-client-event-timeline.spec.js | 73 ++++++-- spec/test-utils/test-utils.ts | 24 ++- spec/unit/event-timeline-set.spec.ts | 77 +++++++- spec/unit/filter-component.spec.ts | 21 +-- spec/unit/room.spec.ts | 174 +++++++++--------- src/client.ts | 10 +- src/models/event-timeline-set.ts | 25 +++ src/models/event-timeline.ts | 2 +- 8 files changed, 271 insertions(+), 135 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 420fee6e588..c165a7057ed 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -70,10 +70,23 @@ const EVENTS = [ }), ]; -const THREAD_ROOT = utils.mkMessage({ +const THREAD_ROOT = utils.mkEvent({ room: roomId, user: userId, - msg: "thread root", + type: "m.room.message", + content: { + "body": "thread root", + "msgtype": "m.text", + }, + unsigned: { + "m.relations": { + "io.element.thread": { + "latest_event": undefined, + "count": 1, + "current_user_participated": true, + }, + }, + }, }); const THREAD_REPLY = utils.mkEvent({ @@ -91,6 +104,8 @@ const THREAD_REPLY = utils.mkEvent({ }, }); +THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY; + // start the client, and wait for it to initialise function startClient(httpBackend, client) { httpBackend.when("GET", "/versions").respond(200, {}); @@ -540,20 +555,46 @@ describe("MatrixClient event timelines", function() { expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy(); }); - it("should return undefined when event is not within a thread but timelineSet is", async () => { + it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => { client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(true); client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId); const threadRoot = new MatrixEvent(THREAD_ROOT); const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); - const timelineSet = thread.timelineSet; + const timelineSet = room.getTimelineSets()[0]; - httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id)) + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id)) .respond(200, function() { - return THREAD_ROOT; + return { + start: "start_token0", + events_before: [], + event: THREAD_ROOT, + events_after: [], + end: "end_token0", + state: [], + }; }); + const [timeline] = await Promise.all([ + client.getEventTimeline(timelineSet, THREAD_ROOT.event_id), + httpBackend.flushAllExpected(), + ]); + + expect(timeline).not.toBe(thread.liveTimeline); + expect(timelineSet.getTimelines()).toContain(timeline); + expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); + }); + + it("should return undefined when event is not in the thread that the given timelineSet is representing", () => { + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const threadRoot = new MatrixEvent(THREAD_ROOT); + const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); + const timelineSet = thread.timelineSet; + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) .respond(200, function() { return { @@ -566,14 +607,13 @@ describe("MatrixClient event timelines", function() { }; }); - const timelinePromise = client.getEventTimeline(timelineSet, EVENTS[0].event_id); - await httpBackend.flushAllExpected(); - - const timeline = await timelinePromise; - expect(timeline).toBeUndefined(); + return Promise.all([ + expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id)).resolves.toBeUndefined(), + httpBackend.flushAllExpected(), + ]); }); - it("should return undefined when event is within a thread but timelineSet is not", async () => { + it("should return undefined when event is within a thread but timelineSet is not", () => { client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(true); client.stopClient(); // we don't need the client to be syncing at this time @@ -592,11 +632,10 @@ describe("MatrixClient event timelines", function() { }; }); - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id); - await httpBackend.flushAllExpected(); - - const timeline = await timelinePromise; - expect(timeline).toBeUndefined(); + return Promise.all([ + expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id)).resolves.toBeUndefined(), + httpBackend.flushAllExpected(), + ]); }); it("should should add lazy loading filter when requested", async () => { diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 0a42578de60..84a9662e419 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -74,7 +74,6 @@ interface IEventOpts { sender?: string; skey?: string; content: IContent; - event?: boolean; user?: string; unsigned?: IUnsigned; redacts?: string; @@ -93,7 +92,9 @@ let testEventIndex = 1; // counter for events, easier for comparison of randomly * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object} a JSON object representing this event. */ -export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent { +export function mkEvent(opts: IEventOpts & { event: true }, client?: MatrixClient): MatrixEvent; +export function mkEvent(opts: IEventOpts & { event?: false }, client?: MatrixClient): object; +export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixClient): object | MatrixEvent { if (!opts.type || !opts.content) { throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); } @@ -143,7 +144,9 @@ interface IPresenceOpts { * @param {Object} opts Values for the presence. * @return {Object|MatrixEvent} The event */ -export function mkPresence(opts: IPresenceOpts): object | MatrixEvent { +export function mkPresence(opts: IPresenceOpts & { event: true }): MatrixEvent; +export function mkPresence(opts: IPresenceOpts & { event?: false }): object; +export function mkPresence(opts: IPresenceOpts & { event?: boolean }): object | MatrixEvent { const event = { event_id: "$" + Math.random() + "-" + Math.random(), type: "m.presence", @@ -182,7 +185,9 @@ interface IMembershipOpts { * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object|MatrixEvent} The event */ -export function mkMembership(opts: IMembershipOpts): object | MatrixEvent { +export function mkMembership(opts: IMembershipOpts & { event: true }): MatrixEvent; +export function mkMembership(opts: IMembershipOpts & { event?: false }): object; +export function mkMembership(opts: IMembershipOpts & { event?: boolean }): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMember, @@ -220,7 +225,9 @@ interface IMessageOpts { * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object|MatrixEvent} The event */ -export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent { +export function mkMessage(opts: IMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent; +export function mkMessage(opts: IMessageOpts & { event?: false }, client?: MatrixClient): object; +export function mkMessage(opts: IMessageOpts & { event?: boolean }, client?: MatrixClient): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMessage, @@ -252,7 +259,12 @@ interface IReplyMessageOpts extends IMessageOpts { * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object|MatrixEvent} The event */ -export function mkReplyMessage(opts: IReplyMessageOpts, client?: MatrixClient): object | MatrixEvent { +export function mkReplyMessage(opts: IReplyMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent; +export function mkReplyMessage(opts: IReplyMessageOpts & { event?: false }, client?: MatrixClient): object; +export function mkReplyMessage( + opts: IReplyMessageOpts & { event?: boolean }, + client?: MatrixClient, +): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMessage, diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 053a78e46c5..42f4bca4de2 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -25,6 +25,8 @@ import { Room, DuplicateStrategy, } from '../../src'; +import { Thread } from "../../src/models/thread"; +import { ReEmitter } from "../../src/ReEmitter"; describe('EventTimelineSet', () => { const roomId = '!foo:bar'; @@ -54,6 +56,7 @@ describe('EventTimelineSet', () => { beforeEach(() => { client = utils.mock(MatrixClient, 'MatrixClient'); + client.reEmitter = utils.mock(ReEmitter, 'ReEmitter'); room = new Room(roomId, client, userA); eventTimelineSet = new EventTimelineSet(room); eventTimeline = new EventTimeline(eventTimelineSet); @@ -62,14 +65,14 @@ describe('EventTimelineSet', () => { user: userA, msg: 'Hi!', event: true, - }) as MatrixEvent; + }); replyEvent = utils.mkReplyMessage({ room: roomId, user: userA, msg: 'Hoo!', event: true, replyToMessage: messageEvent, - }) as MatrixEvent; + }); }); describe('addLiveEvent', () => { @@ -91,7 +94,7 @@ describe('EventTimelineSet', () => { // make a duplicate const duplicateMessageEvent = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }) as MatrixEvent; + }); duplicateMessageEvent.event.event_id = messageEvent.getId(); // Adding the duplicate event should replace the `messageEvent` @@ -220,4 +223,72 @@ describe('EventTimelineSet', () => { }); }); }); + + describe("canContain", () => { + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), + }, + "rel_type": "m.thread", + }, + }, + }, room.client); + + let thread: Thread; + + beforeEach(() => { + (client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true); + thread = new Thread("!thread_id:server", messageEvent, { room, client }); + }); + + it("should throw if timeline set has no room", () => { + const eventTimelineSet = new EventTimelineSet(undefined, {}, client); + expect(() => eventTimelineSet.canContain(messageEvent)).toThrowError(); + }); + + it("should return false if timeline set is for thread but event is not threaded", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + expect(eventTimelineSet.canContain(replyEvent)).toBeFalsy(); + }); + + it("should return false if timeline set it for thread but event it for a different thread", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + const event = mkThreadResponse(replyEvent); + expect(eventTimelineSet.canContain(event)).toBeFalsy(); + }); + + it("should return false if timeline set is not for a thread but event is a thread response", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client); + const event = mkThreadResponse(replyEvent); + expect(eventTimelineSet.canContain(event)).toBeFalsy(); + }); + + it("should return true if the timeline set is not for a thread and the event is a thread root", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client); + expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy(); + }); + + it("should return true if the timeline set is for a thread and the event is its thread root", () => { + const thread = new Thread(messageEvent.getId(), messageEvent, { room, client }); + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + messageEvent.setThread(thread); + expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy(); + }); + + it("should return true if the timeline set is for a thread and the event is a response to it", () => { + const thread = new Thread(messageEvent.getId(), messageEvent, { room, client }); + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + messageEvent.setThread(thread); + const event = mkThreadResponse(messageEvent); + expect(eventTimelineSet.canContain(event)).toBeTruthy(); + }); + }); }); diff --git a/spec/unit/filter-component.spec.ts b/spec/unit/filter-component.spec.ts index 47ffb37cf50..a0a337cd17d 100644 --- a/spec/unit/filter-component.spec.ts +++ b/spec/unit/filter-component.spec.ts @@ -1,7 +1,4 @@ -import { - MatrixEvent, - RelationType, -} from "../../src"; +import { RelationType } from "../../src"; import { FilterComponent } from "../../src/filter-component"; import { mkEvent } from '../test-utils/test-utils'; @@ -14,7 +11,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }) as MatrixEvent; + }); const checkResult = filter.check(event); @@ -28,7 +25,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }) as MatrixEvent; + }); const checkResult = filter.check(event); @@ -55,7 +52,7 @@ describe("Filter Component", function() { }, }, }, - }) as MatrixEvent; + }); expect(filter.check(threadRootNotParticipated)).toBe(false); }); @@ -80,7 +77,7 @@ describe("Filter Component", function() { user: '@someone-else:server.org', room: 'roomId', event: true, - }) as MatrixEvent; + }); expect(filter.check(threadRootParticipated)).toBe(true); }); @@ -100,7 +97,7 @@ describe("Filter Component", function() { [RelationType.Reference]: {}, }, }, - }) as MatrixEvent; + }); expect(filter.check(referenceRelationEvent)).toBe(false); }); @@ -123,7 +120,7 @@ describe("Filter Component", function() { }, room: 'roomId', event: true, - }) as MatrixEvent; + }); const eventWithMultipleRelations = mkEvent({ "type": "m.room.message", @@ -148,7 +145,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }) as MatrixEvent; + }); const noMatchEvent = mkEvent({ "type": "m.room.message", @@ -160,7 +157,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }) as MatrixEvent; + }); expect(filter.check(threadRootEvent)).toBe(true); expect(filter.check(eventWithMultipleRelations)).toBe(true); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index cd6aa477556..267c5610954 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -52,7 +52,7 @@ describe("Room", function() { event: true, user: userA, room: roomId, - }, room.client) as MatrixEvent; + }, room.client); const mkReply = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -67,7 +67,7 @@ describe("Room", function() { }, }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ event: true, @@ -84,7 +84,7 @@ describe("Room", function() { event_id: target.getId(), }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ event: true, @@ -101,7 +101,7 @@ describe("Room", function() { "rel_type": "m.thread", }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkReaction = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -115,7 +115,7 @@ describe("Room", function() { "key": Math.random().toString(), }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -124,7 +124,7 @@ describe("Room", function() { room: roomId, redacts: target.getId(), content: {}, - }, room.client) as MatrixEvent; + }, room.client); beforeEach(function() { room = new Room(roomId, new TestClient(userA, "device").client, userA); @@ -210,11 +210,11 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "changing room name", event: true, - }) as MatrixEvent, + }), utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }) as MatrixEvent, + }), ]; it("Make sure legacy overload passing options directly as parameters still works", () => { @@ -235,7 +235,7 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }) as MatrixEvent; + }); dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); @@ -249,7 +249,7 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }) as MatrixEvent; + }); dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); @@ -277,13 +277,13 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent, + }), utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }) as MatrixEvent, + }), ]; room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( @@ -318,13 +318,13 @@ describe("Room", function() { it("should emit Room.localEchoUpdated when a local echo is updated", function() { const localEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); localEvent.status = EventStatus.SENDING; const localEventId = localEvent.getId(); const remoteEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; const remoteEventId = remoteEvent.getId(); @@ -445,11 +445,11 @@ describe("Room", function() { const newEv = utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }) as MatrixEvent; + }); const oldEv = utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "Old Room Name" }, - }) as MatrixEvent; + }); room.addLiveEvents([newEv]); expect(newEv.sender).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -482,10 +482,10 @@ describe("Room", function() { const newEv = utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent; + }); const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }) as MatrixEvent; + }); room.addLiveEvents([newEv]); expect(newEv.target).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -497,13 +497,13 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent, + }), utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }) as MatrixEvent, + }), ]; room.addEventsToTimeline(events, true, room.getLiveTimeline()); @@ -631,13 +631,13 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent, + }), ]; it("should handle events in the same timeline", function() { @@ -778,26 +778,26 @@ describe("Room", function() { type: EventType.RoomJoinRules, room: roomId, user: userA, content: { join_rule: rule, }, event: true, - }) as MatrixEvent]); + })]); }; const setAltAliases = function(aliases: string[]) { room.addLiveEvents([utils.mkEvent({ type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alt_aliases: aliases, }, event: true, - }) as MatrixEvent]); + })]); }; const setAlias = function(alias: string) { room.addLiveEvents([utils.mkEvent({ type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true, - }) as MatrixEvent]); + })]); }; const setRoomName = function(name: string) { room.addLiveEvents([utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, content: { name: name, }, event: true, - }) as MatrixEvent]); + })]); }; const addMember = function(userId: string, state = "join", opts: any = {}) { opts.room = roomId; @@ -805,7 +805,7 @@ describe("Room", function() { opts.user = opts.user || userId; opts.skey = userId; opts.event = true; - const event = utils.mkMembership(opts) as MatrixEvent; + const event = utils.mkMembership(opts); room.addLiveEvents([event]); return event; }; @@ -1113,7 +1113,7 @@ describe("Room", function() { const eventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", event: true, - }) as MatrixEvent; + }); function mkReceipt(roomId: string, records) { const content = {}; @@ -1179,7 +1179,7 @@ describe("Room", function() { const nextEventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "I AM HERE YOU KNOW", event: true, - }) as MatrixEvent; + }); const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1214,11 +1214,11 @@ describe("Room", function() { const eventTwo = utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent; + }); const eventThree = utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent; + }); const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1266,15 +1266,15 @@ describe("Room", function() { utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent, + }), ]; room.addLiveEvents(events); @@ -1304,15 +1304,15 @@ describe("Room", function() { utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent, + }), ]; room.addLiveEvents(events); @@ -1404,14 +1404,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }) as MatrixEvent; + }); const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }) as MatrixEvent; + }); eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }) as MatrixEvent; + }); room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1430,14 +1430,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }) as MatrixEvent; + }); const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }) as MatrixEvent; + }); eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }) as MatrixEvent; + }); room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1457,7 +1457,7 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1490,7 +1490,7 @@ describe("Room", function() { const room = new Room(roomId, null, userA); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1567,7 +1567,7 @@ describe("Room", function() { room: roomId, event: true, name: "User A", - }) as MatrixEvent; + }); it("should load members from server on first call", async function() { const client = createClientMock([memberEvent]); @@ -1587,7 +1587,7 @@ describe("Room", function() { room: roomId, event: true, name: "Ms A", - }) as MatrixEvent; + }); const client = createClientMock([memberEvent2], [memberEvent]); const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); @@ -1658,7 +1658,7 @@ describe("Room", function() { mship: "join", room: roomId, event: true, - }) as MatrixEvent]); + })]); expect(room.guessDMUserId()).toEqual(userB); }); it("should return self if only member present", function() { @@ -1691,11 +1691,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1706,11 +1706,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "ban", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); @@ -1721,11 +1721,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "invite", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1736,11 +1736,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "leave", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); @@ -1751,15 +1751,15 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); }); @@ -1770,19 +1770,19 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), utils.mkMembership({ user: userD, mship: "join", room: roomId, event: true, name: "User D", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); }); @@ -1795,18 +1795,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, content: { service_members: [], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1817,11 +1817,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", @@ -1830,7 +1830,7 @@ describe("Room", function() { content: { service_members: 1, }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1841,18 +1841,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, content: { service_members: userB, }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1863,18 +1863,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, content: { service_members: [userB], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); @@ -1885,22 +1885,22 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, user: userA, content: { service_members: [userC], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1911,22 +1911,22 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, user: userA, content: { service_members: [userB, userC], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); @@ -1937,18 +1937,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, user: userA, content: { service_members: [userC], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); diff --git a/src/client.ts b/src/client.ts index d8003a1f391..3058bca8658 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5283,15 +5283,7 @@ export class MatrixClient extends TypedEventEmitter Date: Thu, 16 Jun 2022 15:00:45 +0200 Subject: [PATCH 33/41] Live location share - add start time leniency (PSF-1081) (#2465) * remove some of the confusing time travel in beacon.spec * test cases * add start time leniency to beacon liveness check --- spec/test-utils/beacon.ts | 11 ++- spec/unit/models/beacon.spec.ts | 156 ++++++++++++++++++++++++++++---- src/models/beacon.ts | 10 +- 3 files changed, 154 insertions(+), 23 deletions(-) diff --git a/spec/test-utils/beacon.ts b/spec/test-utils/beacon.ts index 0823cca0c72..252c85c8150 100644 --- a/spec/test-utils/beacon.ts +++ b/spec/test-utils/beacon.ts @@ -27,6 +27,7 @@ type InfoContentProps = { isLive?: boolean; assetType?: LocationAssetType; description?: string; + timestamp?: number; }; const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = { timeout: 3600000, @@ -44,7 +45,11 @@ export const makeBeaconInfoEvent = ( eventId?: string, ): MatrixEvent => { const { - timeout, isLive, description, assetType, + timeout, + isLive, + description, + assetType, + timestamp, } = { ...DEFAULT_INFO_CONTENT_PROPS, ...contentProps, @@ -53,10 +58,10 @@ export const makeBeaconInfoEvent = ( type: M_BEACON_INFO.name, room_id: roomId, state_key: sender, - content: makeBeaconInfoContent(timeout, isLive, description, assetType), + content: makeBeaconInfoContent(timeout, isLive, description, assetType, timestamp), }); - event.event.origin_server_ts = Date.now(); + event.event.origin_server_ts = timestamp || Date.now(); // live beacons use the beacon_info event id // set or default this diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 30052ae7ca1..7fb79fe2dce 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from "../../../src"; import { isTimestampInDuration, Beacon, @@ -65,9 +66,9 @@ describe('Beacon', () => { // beacon_info events // created 'an hour ago' // without timeout of 3 hours - let liveBeaconEvent; - let notLiveBeaconEvent; - let user2BeaconEvent; + let liveBeaconEvent: MatrixEvent; + let notLiveBeaconEvent: MatrixEvent; + let user2BeaconEvent: MatrixEvent; const advanceDateAndTime = (ms: number) => { // bc liveness check uses Date.now we have to advance this mock @@ -77,21 +78,24 @@ describe('Beacon', () => { }; beforeEach(() => { - // go back in time to create the beacon - jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS); liveBeaconEvent = makeBeaconInfoEvent( userId, roomId, { timeout: HOUR_MS * 3, isLive: true, + timestamp: now - HOUR_MS, }, '$live123', ); notLiveBeaconEvent = makeBeaconInfoEvent( userId, roomId, - { timeout: HOUR_MS * 3, isLive: false }, + { + timeout: HOUR_MS * 3, + isLive: false, + timestamp: now - HOUR_MS, + }, '$dead123', ); user2BeaconEvent = makeBeaconInfoEvent( @@ -100,11 +104,12 @@ describe('Beacon', () => { { timeout: HOUR_MS * 3, isLive: true, + timestamp: now - HOUR_MS, }, '$user2live123', ); - // back to now + // back to 'now' jest.spyOn(global.Date, 'now').mockReturnValue(now); }); @@ -131,17 +136,81 @@ describe('Beacon', () => { }); it('returns false when beacon is expired', () => { - // time travel to beacon creation + 3 hours - jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS); - const beacon = new Beacon(liveBeaconEvent); + const expiredBeaconEvent = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now - HOUR_MS * 2, + }, + '$user2live123', + ); + const beacon = new Beacon(expiredBeaconEvent); expect(beacon.isLive).toEqual(false); }); - it('returns false when beacon timestamp is in future', () => { - // time travel to before beacon events timestamp - // event was created now - 1 hour - jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS); - const beacon = new Beacon(liveBeaconEvent); + it('returns false when beacon timestamp is in future by an hour', () => { + const beaconStartsInHour = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now + HOUR_MS, + }, + '$user2live123', + ); + const beacon = new Beacon(beaconStartsInHour); + expect(beacon.isLive).toEqual(false); + }); + + it('returns true when beacon timestamp is one minute in the future', () => { + const beaconStartsInOneMin = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now + 60000, + }, + '$user2live123', + ); + const beacon = new Beacon(beaconStartsInOneMin); + expect(beacon.isLive).toEqual(true); + }); + + it('returns true when beacon timestamp is one minute before expiry', () => { + // this test case is to check the start time leniency doesn't affect + // strict expiry time checks + const expiresInOneMin = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now - HOUR_MS + 60000, + }, + '$user2live123', + ); + const beacon = new Beacon(expiresInOneMin); + expect(beacon.isLive).toEqual(true); + }); + + it('returns false when beacon timestamp is one minute after expiry', () => { + // this test case is to check the start time leniency doesn't affect + // strict expiry time checks + const expiredOneMinAgo = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now - HOUR_MS - 60000, + }, + '$user2live123', + ); + const beacon = new Beacon(expiredOneMinAgo); expect(beacon.isLive).toEqual(false); }); @@ -232,19 +301,17 @@ describe('Beacon', () => { }); it('checks liveness of beacon at expected start time', () => { - // go forward in time to make beacon with timestamp in future - jest.spyOn(global.Date, 'now').mockReturnValue(now + HOUR_MS); const futureBeaconEvent = makeBeaconInfoEvent( userId, roomId, { timeout: HOUR_MS * 3, isLive: true, + // start timestamp hour in future + timestamp: now + HOUR_MS, }, '$live123', ); - // go back to now - jest.spyOn(global.Date, 'now').mockReturnValue(now); const beacon = new Beacon(futureBeaconEvent); expect(beacon.isLive).toBeFalsy(); @@ -345,6 +412,57 @@ describe('Beacon', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + describe('when beacon is live with a start timestamp is in the future', () => { + it('ignores locations before the beacon start timestamp', () => { + const startTimestamp = now + 60000; + const beacon = new Beacon(makeBeaconInfoEvent( + userId, + roomId, + { isLive: true, timeout: 60000, timestamp: startTimestamp }, + )); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.addLocations([ + // beacon has now + 60000 live period + makeBeaconEvent( + userId, + { + beaconInfoId: beacon.beaconInfoId, + // now < location timestamp < beacon timestamp + timestamp: now + 10, + }, + ), + ]); + + expect(beacon.latestLocationState).toBeFalsy(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + it('sets latest location when location timestamp is after startTimestamp', () => { + const startTimestamp = now + 60000; + const beacon = new Beacon(makeBeaconInfoEvent( + userId, + roomId, + { isLive: true, timeout: 600000, timestamp: startTimestamp }, + )); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.addLocations([ + // beacon has now + 600000 live period + makeBeaconEvent( + userId, + { + beaconInfoId: beacon.beaconInfoId, + // now < beacon timestamp < location timestamp + timestamp: startTimestamp + 10, + }, + ), + ]); + + expect(beacon.latestLocationState).toBeTruthy(); + expect(emitSpy).toHaveBeenCalled(); + }); + }); + it('sets latest location state to most recent location', () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); const emitSpy = jest.spyOn(beacon, 'emit'); diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 05562574322..ddc72fddbd8 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -185,8 +185,16 @@ export class Beacon extends TypedEventEmitter Date.now() ? + this._beaconInfo?.timestamp - 360000 /* 6min */ : + this._beaconInfo?.timestamp; this._isLive = this._beaconInfo?.live && - isTimestampInDuration(this._beaconInfo?.timestamp, this._beaconInfo?.timeout, Date.now()); + isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now()); if (prevLiveness !== this.isLive) { this.emit(BeaconEvent.LivenessChange, this.isLive, this); From fc946ab0fa156449717a5963889e05b3c004b9a1 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 17 Jun 2022 13:39:23 +0200 Subject: [PATCH 34/41] expose latestLocationEvent on beacon model (#2467) --- spec/unit/models/beacon.spec.ts | 2 ++ src/models/beacon.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 7fb79fe2dce..73b6bc552d2 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -492,6 +492,7 @@ describe('Beacon', () => { // the newest valid location expect(beacon.latestLocationState).toEqual(expectedLatestLocation); + expect(beacon.latestLocationEvent).toEqual(locations[1]); expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation); }); @@ -510,6 +511,7 @@ describe('Beacon', () => { expect(beacon.latestLocationState).toEqual(expect.objectContaining({ uri: 'geo:bar', })); + expect(beacon.latestLocationEvent).toEqual(newerLocation); const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); diff --git a/src/models/beacon.ts b/src/models/beacon.ts index ddc72fddbd8..9df62bbe2b1 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -55,7 +55,7 @@ export class Beacon extends TypedEventEmitter; - private _latestLocationState: BeaconLocationState | undefined; + private _latestLocationEvent: MatrixEvent | undefined; constructor( private rootEvent: MatrixEvent, @@ -90,7 +90,11 @@ export class Beacon extends TypedEventEmitter { - this._latestLocationState = undefined; + this._latestLocationEvent = undefined; this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); }; From 58227307974a9730cf2e4a2900284cb4128c438a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Mon, 20 Jun 2022 16:21:46 +0200 Subject: [PATCH 35/41] Implement MSC3827: Filtering of `/publicRooms` by room type (#2469) --- src/@types/requests.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/@types/requests.ts b/src/@types/requests.ts index f03f6341bee..160a0af25bb 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -22,6 +22,7 @@ import { IRoomEventFilter } from "../filter"; import { Direction } from "../models/event-timeline"; import { PushRuleAction } from "./PushRules"; import { IRoomEvent } from "../sync-accumulator"; +import { RoomType } from "./event"; // allow camelcase as these are things that go onto the wire /* eslint-disable camelcase */ @@ -111,7 +112,8 @@ export interface IRoomDirectoryOptions { limit?: number; since?: string; filter?: { - generic_search_term: string; + generic_search_term?: string; + "org.matrix.msc3827.room_types"?: Array; }; include_all_networks?: boolean; third_party_instance_id?: string; From 7d2f4cfd423e258455f2101cddc249e28b8ed82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 26 Jun 2022 08:43:01 +0200 Subject: [PATCH 36/41] Send call version `1` as a string (#2471) --- src/webrtc/call.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 9ae539d6d8b..b2d111f4a13 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -218,7 +218,7 @@ export enum CallErrorCode { /** * The version field that we set in m.call.* events */ -const VOIP_PROTO_VERSION = 1; +const VOIP_PROTO_VERSION = "1"; /** The fallback ICE server to use for STUN or TURN protocols. */ const FALLBACK_ICE_SERVER = 'stun:turn.matrix.org'; @@ -909,7 +909,7 @@ export class MatrixCall extends TypedEventEmitter= 1) || reason !== CallErrorCode.UserHangup) { + if ((this.opponentVersion && this.opponentVersion !== 0) || reason !== CallErrorCode.UserHangup) { content["reason"] = reason; } this.sendVoipEvent(EventType.CallHangup, content); @@ -925,7 +925,7 @@ export class MatrixCall extends TypedEventEmitter Date: Mon, 27 Jun 2022 09:13:18 +0200 Subject: [PATCH 37/41] test typescriptification - backup.spec (#2468) * renamed: spec/unit/crypto/crypto-utils.js -> spec/unit/crypto/crypto-utils.ts * ts fixes in crypto-utils * renamed: spec/unit/crypto/backup.spec.js -> spec/unit/crypto/backup.spec.ts * ts fixes in backup.spec * remove fit * remove debug --- .../crypto/{backup.spec.js => backup.spec.ts} | 231 ++++++++---------- .../{crypto-utils.js => crypto-utils.ts} | 14 +- 2 files changed, 111 insertions(+), 134 deletions(-) rename spec/unit/crypto/{backup.spec.js => backup.spec.ts} (78%) rename spec/unit/crypto/{crypto-utils.js => crypto-utils.ts} (78%) diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.ts similarity index 78% rename from spec/unit/crypto/backup.spec.js rename to spec/unit/crypto/backup.spec.ts index cab0c0d0d9f..6759fe16152 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.ts @@ -15,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MockedObject } from "jest-mock"; + import '../../olm-loader'; import { logger } from "../../../src/logger"; import * as olmlib from "../../../src/crypto/olmlib"; @@ -22,12 +24,13 @@ import { MatrixClient } from "../../../src/client"; import { MatrixEvent } from "../../../src/models/event"; import * as algorithms from "../../../src/crypto/algorithms"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; -import { MockStorageApi } from "../../MockStorageApi"; import * as testUtils from "../../test-utils/test-utils"; import { OlmDevice } from "../../../src/crypto/OlmDevice"; import { Crypto } from "../../../src/crypto"; import { resetCrossSigningKeys } from "./crypto-utils"; import { BackupManager } from "../../../src/crypto/backup"; +import { StubStore } from "../../../src/store/stub"; +import { IAbortablePromise, MatrixScheduler } from '../../../src'; const Olm = global.Olm; @@ -92,8 +95,8 @@ const AES256_KEY_BACKUP_DATA = { }; const CURVE25519_BACKUP_INFO = { - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - version: 1, + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, + version: '1', auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, @@ -101,7 +104,7 @@ const CURVE25519_BACKUP_INFO = { const AES256_BACKUP_INFO = { algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2", - version: 1, + version: '1', auth_data: { // FIXME: add iv and mac }, @@ -121,21 +124,14 @@ function makeTestClient(cryptoStore) { const scheduler = [ "getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}); - const store = [ - "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", - "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", - "storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter", - "storeFilter", "getSyncAccumulator", "startup", "deleteAllData", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}); - store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null)); - store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null)); - store.setSyncData = jest.fn().mockReturnValue(Promise.resolve(null)); + ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject; + const store = new StubStore(); + return new MatrixClient({ baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", - request: function() {}, // NOP + request: jest.fn(), // NOP store: store, scheduler: scheduler, userId: "@alice:bar", @@ -158,7 +154,6 @@ describe("MegolmBackup", function() { let olmDevice; let mockOlmLib; let mockCrypto; - let mockStorage; let cryptoStore; let megolmDecryption; beforeEach(async function() { @@ -170,8 +165,7 @@ describe("MegolmBackup", function() { ); mockCrypto.backupInfo = CURVE25519_BACKUP_INFO; - mockStorage = new MockStorageApi(); - cryptoStore = new MemoryCryptoStore(mockStorage); + cryptoStore = new MemoryCryptoStore(); olmDevice = new OlmDevice(cryptoStore); @@ -184,7 +178,6 @@ describe("MegolmBackup", function() { describe("backup", function() { let mockBaseApis; - let realSetTimeout; beforeEach(function() { mockBaseApis = {}; @@ -202,14 +195,14 @@ describe("MegolmBackup", function() { // clobber the setTimeout function to run 100x faster. // ideally we would use lolex, but we have no oportunity // to tick the clock between the first try and the retry. - realSetTimeout = global.setTimeout; - global.setTimeout = function(f, n) { + const realSetTimeout = global.setTimeout; + jest.spyOn(global, 'setTimeout').mockImplementation(function(f, n) { return realSetTimeout(f, n/100); - }; + }); }); afterEach(function() { - global.setTimeout = realSetTimeout; + jest.spyOn(global, 'setTimeout').mockRestore(); }); it('automatically calls the key back up', function() { @@ -289,16 +282,16 @@ describe("MegolmBackup", function() { txn); }); }) - .then(() => { - client.enableKeyBackup({ - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - version: 1, + .then(async () => { + await client.enableKeyBackup({ + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, + version: '1', auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, }); let numCalls = 0; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { client.http.authedRequest = function( callback, method, path, queryParams, data, opts, ) { @@ -307,17 +300,17 @@ describe("MegolmBackup", function() { if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); - expect(queryParams.version).toBe(1); + expect(queryParams.version).toBe('1'); expect(data.rooms[ROOM_ID].sessions).toBeDefined(); expect(data.rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); resolve(); - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; }; client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -371,17 +364,17 @@ describe("MegolmBackup", function() { txn); }); }) - .then(() => { - client.enableKeyBackup({ + .then(async () => { + await client.enableKeyBackup({ algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2", - version: 1, + version: '1', auth_data: { iv: "PsCAtR7gMc4xBd9YS3A9Ow", mac: "ZSDsTFEZK7QzlauCLMleUcX96GQZZM7UNtk4sripSqQ", }, }); let numCalls = 0; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { client.http.authedRequest = function( callback, method, path, queryParams, data, opts, ) { @@ -390,17 +383,17 @@ describe("MegolmBackup", function() { if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); - expect(queryParams.version).toBe(1); + expect(queryParams.version).toBe('1'); expect(data.rooms[ROOM_ID].sessions).toBeDefined(); expect(data.rooms[ROOM_ID].sessions).toHaveProperty( groupSession.session_id(), ); resolve(); - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; }; client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", @@ -432,19 +425,12 @@ describe("MegolmBackup", function() { megolmDecryption.olmlib = mockOlmLib; await client.initCrypto(); - let privateKeys; - client.uploadDeviceSigningKeys = async function(e) {return;}; - client.uploadKeySignatures = async function(e) {return;}; - client.on("crossSigning.saveCrossSigningKeys", function(e) { - privateKeys = e; - }); - client.on("crossSigning.getKey", function(e) { - e.done(privateKeys[e.type]); - }); + client.uploadDeviceSigningKeys = async function(e) {return {};}; + client.uploadKeySignatures = async function(e) {return { failures: {} };}; await resetCrossSigningKeys(client); let numCalls = 0; await Promise.all([ - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { let backupInfo; client.http.authedRequest = function( callback, method, path, queryParams, data, opts, @@ -461,24 +447,24 @@ describe("MegolmBackup", function() { ); } catch (e) { reject(e); - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; } backupInfo = data; - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; } else if (numCalls === 2) { expect(method).toBe("GET"); expect(path).toBe("/room_keys/version"); resolve(); - return Promise.resolve(backupInfo); + return Promise.resolve(backupInfo) as IAbortablePromise; } else { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many times")); - return Promise.resolve({}); + return Promise.resolve({}) as IAbortablePromise; } }; }), client.createKeyBackupVersion({ - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, @@ -488,7 +474,7 @@ describe("MegolmBackup", function() { client.stopClient(); }); - it('retries when a backup fails', function() { + it('retries when a backup fails', async function() { const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); const ibGroupSession = new Olm.InboundGroupSession(); @@ -497,21 +483,13 @@ describe("MegolmBackup", function() { const scheduler = [ "getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}); - const store = [ - "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", - "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", - "storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter", - "storeFilter", "getSyncAccumulator", "startup", "deleteAllData", - ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}); - store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null)); - store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null)); - store.setSyncData = jest.fn().mockReturnValue(Promise.resolve(null)); + ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject; + const store = new StubStore(); const client = new MatrixClient({ baseUrl: "https://my.home.server", idBaseUrl: "https://identity.server", accessToken: "my.access.token", - request: function() {}, // NOP + request: jest.fn(), // NOP store: store, scheduler: scheduler, userId: "@alice:bar", @@ -529,71 +507,68 @@ describe("MegolmBackup", function() { megolmDecryption.olmlib = mockOlmLib; - return client.initCrypto() - .then(() => { - return cryptoStore.doTxn( - "readwrite", - [cryptoStore.STORE_SESSION], - (txn) => { - cryptoStore.addEndToEndInboundGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - { - forwardingCurve25519KeyChain: undefined, - keysClaimed: { - ed25519: "SENDER_ED25519", - }, - room_id: ROOM_ID, - session: ibGroupSession.pickle(olmDevice.pickleKey), - }, - txn); - }); - }) - .then(() => { - client.enableKeyBackup({ - algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", - version: 1, - auth_data: { - public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + await client.initCrypto(); + await cryptoStore.doTxn( + "readwrite", + [cryptoStore.STORE_SESSION], + (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined, + keysClaimed: { + ed25519: "SENDER_ED25519", + }, + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice.pickleKey), }, - }); - let numCalls = 0; - return new Promise((resolve, reject) => { - client.http.authedRequest = function( - callback, method, path, queryParams, data, opts, - ) { - ++numCalls; - expect(numCalls).toBeLessThanOrEqual(2); - if (numCalls >= 3) { - // exit out of retry loop if there's something wrong - reject(new Error("authedRequest called too many timmes")); - return Promise.resolve({}); - } - expect(method).toBe("PUT"); - expect(path).toBe("/room_keys/keys"); - expect(queryParams.version).toBe(1); - expect(data.rooms[ROOM_ID].sessions).toBeDefined(); - expect(data.rooms[ROOM_ID].sessions).toHaveProperty( - groupSession.session_id(), - ); - if (numCalls > 1) { - resolve(); - return Promise.resolve({}); - } else { - return Promise.reject( - new Error("this is an expected failure"), - ); - } - }; - client.crypto.backupManager.backupGroupSession( - "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", - groupSession.session_id(), - ); - }).then(() => { - expect(numCalls).toBe(2); - client.stopClient(); - }); + txn); }); + + await client.enableKeyBackup({ + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, + version: '1', + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }); + let numCalls = 0; + + await new Promise((resolve, reject) => { + client.http.authedRequest = function( + callback, method, path, queryParams, data, opts, + ) { + ++numCalls; + expect(numCalls).toBeLessThanOrEqual(2); + if (numCalls >= 3) { + // exit out of retry loop if there's something wrong + reject(new Error("authedRequest called too many timmes")); + return Promise.resolve({}) as IAbortablePromise; + } + expect(method).toBe("PUT"); + expect(path).toBe("/room_keys/keys"); + expect(queryParams.version).toBe('1'); + expect(data.rooms[ROOM_ID].sessions).toBeDefined(); + expect(data.rooms[ROOM_ID].sessions).toHaveProperty( + groupSession.session_id(), + ); + if (numCalls > 1) { + resolve(); + return Promise.resolve({}) as IAbortablePromise; + } else { + return Promise.reject( + new Error("this is an expected failure"), + ) as IAbortablePromise; + } + }; + return client.crypto.backupManager.backupGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + ); + }); + expect(numCalls).toBe(2); + client.stopClient(); }); }); diff --git a/spec/unit/crypto/crypto-utils.js b/spec/unit/crypto/crypto-utils.ts similarity index 78% rename from spec/unit/crypto/crypto-utils.js rename to spec/unit/crypto/crypto-utils.ts index ecc6fc4b0ae..3535edaabe7 100644 --- a/spec/unit/crypto/crypto-utils.js +++ b/spec/unit/crypto/crypto-utils.ts @@ -1,11 +1,13 @@ +import { IRecoveryKey } from '../../../src/crypto/api'; +import { CrossSigningLevel } from '../../../src/crypto/CrossSigning'; import { IndexedDBCryptoStore } from '../../../src/crypto/store/indexeddb-crypto-store'; // needs to be phased out and replaced with bootstrapSecretStorage, // but that is doing too much extra stuff for it to be an easy transition. -export async function resetCrossSigningKeys(client, { - level, - authUploadDeviceSigningKeys = async func => await func(), -} = {}) { +export async function resetCrossSigningKeys( + client, + { level }: { level?: CrossSigningLevel} = {}, +): Promise { const crypto = client.crypto; const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys); @@ -30,14 +32,14 @@ export async function resetCrossSigningKeys(client, { await crypto.afterCrossSigningLocalKeyChange(); } -export async function createSecretStorageKey() { +export async function createSecretStorageKey(): Promise { const decryption = new global.Olm.PkDecryption(); const storagePublicKey = decryption.generate_key(); const storagePrivateKey = decryption.get_private_key(); decryption.free(); return { // `pubkey` not used anymore with symmetric 4S - keyInfo: { pubkey: storagePublicKey }, + keyInfo: { pubkey: storagePublicKey, key: undefined }, privateKey: storagePrivateKey, }; } From 7521f82cac8d8abd0b7aa64cee6802c79c0bdd0a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 28 Jun 2022 16:06:58 +0100 Subject: [PATCH 38/41] Prepare changelog for v19.0.0-rc.1 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf48bc37eaf..713992cd53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +Changes in [19.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.0.0-rc.1) (2022-06-28) +============================================================================================================ + +## 🚨 BREAKING CHANGES + * Remove unused sessionStore ([\#2455](https://github.com/matrix-org/matrix-js-sdk/pull/2455)). + +## ✨ Features + * Implement MSC3827: Filtering of `/publicRooms` by room type ([\#2469](https://github.com/matrix-org/matrix-js-sdk/pull/2469)). + * expose latestLocationEvent on beacon model ([\#2467](https://github.com/matrix-org/matrix-js-sdk/pull/2467)). Contributed by @kerryarchibald. + * Live location share - add start time leniency ([\#2465](https://github.com/matrix-org/matrix-js-sdk/pull/2465)). Contributed by @kerryarchibald. + * Log real errors and not just their messages, traces are useful ([\#2464](https://github.com/matrix-org/matrix-js-sdk/pull/2464)). + * Various changes to `src/crypto` files for correctness ([\#2137](https://github.com/matrix-org/matrix-js-sdk/pull/2137)). Contributed by @ShadowJonathan. + * Update MSC3786 implementation: Check the `state_key` ([\#2429](https://github.com/matrix-org/matrix-js-sdk/pull/2429)). + * Timeline needs to refresh when we see a MSC2716 marker event ([\#2299](https://github.com/matrix-org/matrix-js-sdk/pull/2299)). Contributed by @MadLittleMods. + * Try to load keys from key backup when a message fails to decrypt ([\#2373](https://github.com/matrix-org/matrix-js-sdk/pull/2373)). Fixes vector-im/element-web#21026. Contributed by @duxovni. + +## 🐛 Bug Fixes + * Send call version `1` as a string ([\#2471](https://github.com/matrix-org/matrix-js-sdk/pull/2471)). Fixes vector-im/element-web#22629. + * Fix issue with `getEventTimeline` returning undefined for thread roots in main timeline ([\#2454](https://github.com/matrix-org/matrix-js-sdk/pull/2454)). Fixes vector-im/element-web#22539. + * Add missing `type` property on `IAuthData` ([\#2463](https://github.com/matrix-org/matrix-js-sdk/pull/2463)). + * Clearly indicate that `lastReply` on a Thread can return falsy ([\#2462](https://github.com/matrix-org/matrix-js-sdk/pull/2462)). + * Fix issues with getEventTimeline and thread roots ([\#2444](https://github.com/matrix-org/matrix-js-sdk/pull/2444)). Fixes vector-im/element-web#21613. + * Live location sharing - monitor liveness of beacons yet to start ([\#2437](https://github.com/matrix-org/matrix-js-sdk/pull/2437)). Contributed by @kerryarchibald. + * Refactor Relations to not be per-EventTimelineSet ([\#2412](https://github.com/matrix-org/matrix-js-sdk/pull/2412)). Fixes #2399 and vector-im/element-web#22298. + * Add tests for sendEvent threadId handling ([\#2435](https://github.com/matrix-org/matrix-js-sdk/pull/2435)). Fixes vector-im/element-web#22433. + * Make sure `encryptAndSendKeysToDevices` assumes devices are unique per-user. ([\#2136](https://github.com/matrix-org/matrix-js-sdk/pull/2136)). Fixes #2135. Contributed by @ShadowJonathan. + * Don't bug the user while re-checking key backups after decryption failures ([\#2430](https://github.com/matrix-org/matrix-js-sdk/pull/2430)). Fixes vector-im/element-web#22416. Contributed by @duxovni. + Changes in [18.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.1.0) (2022-06-07) ================================================================================================== From 4b3aac21db4d7354b08fb7c7f1eaa45feead9ccd Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 28 Jun 2022 16:06:58 +0100 Subject: [PATCH 39/41] v19.0.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0cd570a1459..2130494a643 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "18.1.0", + "version": "19.0.0-rc.1", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=12.9.0" @@ -32,7 +32,7 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", + "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -125,5 +125,6 @@ "jestSonar": { "reportPath": "coverage", "sonar56x": true - } + }, + "typings": "./lib/index.d.ts" } From b899fd6ccc9feedbfc86e2045296952ed2749413 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Jul 2022 14:06:48 +0100 Subject: [PATCH 40/41] Prepare changelog for v19.0.0 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 713992cd53c..9c8e28925d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ -Changes in [19.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.0.0-rc.1) (2022-06-28) -============================================================================================================ +Changes in [19.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.0.0) (2022-07-05) +================================================================================================== ## 🚨 BREAKING CHANGES * Remove unused sessionStore ([\#2455](https://github.com/matrix-org/matrix-js-sdk/pull/2455)). From 1cb32c174bfbc296f134c2a7d12b5943f06d7527 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 Jul 2022 14:06:48 +0100 Subject: [PATCH 41/41] v19.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2130494a643..8826854692d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "19.0.0-rc.1", + "version": "19.0.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=12.9.0"