Compare commits
569 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1476ffbd15 | |||
| 62f0a65472 | |||
| 2ef7ae7661 | |||
| 61c0a49971 | |||
| 2172f28888 | |||
| 2e9b34e0c3 | |||
| 5a782b7377 | |||
| 54bc807056 | |||
| 9e07710d80 | |||
| e9ed91d800 | |||
| 88ba4fad71 | |||
| 21b3471453 | |||
| 0ada9803ab | |||
| 1744f0e97b | |||
| fd0c4a7f56 | |||
| 615f7f9e72 | |||
| 77259e81c9 | |||
| 2193cd9d1c | |||
| 6d28154dcd | |||
| 83d447adfe | |||
| 73c9f4e322 | |||
| e6fa4cdb3c | |||
| a04653a72c | |||
| 5f9341f39c | |||
| 906946c419 | |||
| 4397b9d640 | |||
| 90da2cf439 | |||
| 6edd45787b | |||
| 84444ec11e | |||
| 0e95df5dba | |||
| 29b815b678 | |||
| 0cf056958b | |||
| 79d4113a6b | |||
| 8a80886358 | |||
| de7959de6c | |||
| 533c21a515 | |||
| 6b018b6927 | |||
| 38c3abb364 | |||
| a47f319665 | |||
| ecef9fd755 | |||
| 7dffd8ffd3 | |||
| 66492e7ba8 | |||
| 43b2404865 | |||
| fed9910fa1 | |||
| f77662406c | |||
| 8cc0cf1a70 | |||
| dfa2429094 | |||
| 3e2460707c | |||
| 706c084fa7 | |||
| eb7faa6c07 | |||
| d45a0b894a | |||
| 102739e0fb | |||
| 0d7e4a0fa5 | |||
| d4628e78d4 | |||
| 0b193f4665 | |||
| 8ef2e848b9 | |||
| d92936fba5 | |||
| f005984df3 | |||
| 13fec49e74 | |||
| 008294cfc6 | |||
| b05f933d83 | |||
| b186d79dde | |||
| e82b5fe1db | |||
| 9602aa88ea | |||
| 0fb3dc1b13 | |||
| aeede332be | |||
| b052950a19 | |||
| 1cb5fff5a1 | |||
| 01226e41d9 | |||
| e3919fd93b | |||
| 3b88ea19b7 | |||
| dcf26f3e48 | |||
| 3385adf5f6 | |||
| 9db6ce107a | |||
| d5b22e1deb | |||
| a5e606a1e7 | |||
| f2471b6dbd | |||
| dcf71e0c8f | |||
| 77267e393c | |||
| 1fdc0af5b7 | |||
| d2b782a2f5 | |||
| 5df4ebaada | |||
| e68a1471c1 | |||
| e42dd74426 | |||
| 2751e191d3 | |||
| b5b86bf1b5 | |||
| 4990bf5ca0 | |||
| b8fa030d5d | |||
| b606d1e54b | |||
| cd7c519dc4 | |||
| 30dd28960c | |||
| 5b635df08d | |||
| 592c497902 | |||
| 8e3f2f3262 | |||
| 5751df1288 | |||
| 40a71101e2 | |||
| 3f095caf2d | |||
| 12a94bdd94 | |||
| 1c1ac137d3 | |||
| 89cabc4912 | |||
| 5be4548b3d | |||
| 09de76bd43 | |||
| 3a694f4998 | |||
| 3a8a1389f5 | |||
| c271e1533a | |||
| 722debe8f9 | |||
| 5165899e82 | |||
| 1828826661 | |||
| 24cee68fa2 | |||
| e645af1fc5 | |||
| de64779c27 | |||
| acbcb4658a | |||
| 815484b543 | |||
| 5a3d1a2a67 | |||
| 18626169e4 | |||
| e4a9f958a0 | |||
| ff29de743c | |||
| 5a68861418 | |||
| e285932776 | |||
| 2af0706b16 | |||
| 4382d2a425 | |||
| 9de4a057df | |||
| b703d4a2cc | |||
| d1dec4cd08 | |||
| 326a13bcfe | |||
| e8fb47fdca | |||
| bd66e3859d | |||
| 96e484a3fe | |||
| 3e646bdfa0 | |||
| 48c4127035 | |||
| f16a6bc654 | |||
| f884c78579 | |||
| 3c59476cf7 | |||
| c8f6c4dd0d | |||
| e8c89e9977 | |||
| df78d7cf67 | |||
| 80fec814a2 | |||
| 8b9672ba43 | |||
| ca00094e67 | |||
| 9c6d5a6c55 | |||
| b77fe465f7 | |||
| 8d93f49443 | |||
| f03fbed668 | |||
| c84efb57ef | |||
| 49f11578f7 | |||
| 8df4be0939 | |||
| 80cdbe1058 | |||
| 9c62d15447 | |||
| afc70528cc | |||
| f938d10f7b | |||
| 22f0b781ea | |||
| 1bae10c4b2 | |||
| 9b5b533c6f | |||
| c425945353 | |||
| 0545f6df09 | |||
| d14fc426e6 | |||
| 9f30defcd1 | |||
| 9b175a4985 | |||
| 826ea5bc58 | |||
| 7b316613f6 | |||
| 387b3485ae | |||
| 9f6073478f | |||
| b47c87f909 | |||
| c66850e897 | |||
| 2766146c49 | |||
| 61497c9a8f | |||
| 5981feeb44 | |||
| 51218ddc1d | |||
| b907433d38 | |||
| 32c0b81332 | |||
| 634b8ebbb4 | |||
| 4ab8066e1f | |||
| 13dccb3d71 | |||
| ef1f5bf232 | |||
| a03e3dd501 | |||
| 3cfad3cdeb | |||
| 60c715d5df | |||
| c2942ddbc7 | |||
| 2d7fdde7ed | |||
| cf34e90cb4 | |||
| f0fa4d2cc8 | |||
| c52e4b6329 | |||
| 9451b55985 | |||
| f41fa84e72 | |||
| 3c4134537a | |||
| 39714bfe6f | |||
| f5f6100b1e | |||
| ecd700a36e | |||
| ea7042efb9 | |||
| 04a6c4e6c4 | |||
| 0329824cab | |||
| 3351c4f57a | |||
| e70a1a1eff | |||
| 1a5af9d8e3 | |||
| 258f157ebc | |||
| dfb079a76f | |||
| 858155e0ef | |||
| 946a1cef0f | |||
| 6c1fdbb7e9 | |||
| 8cd7c96496 | |||
| cc9d530bcf | |||
| cabf6da6a7 | |||
| 895a82efcd | |||
| d4414341d6 | |||
| f173014fc0 | |||
| 81cb44db7f | |||
| 71f9b25db7 | |||
| 9c5c7ddb17 | |||
| feb424b0a9 | |||
| 648a1a09b1 | |||
| 56c5375bbd | |||
| b29e1e9d21 | |||
| 7ade461a4c | |||
| b5414ea914 | |||
| 711bf4710d | |||
| 48b60bb885 | |||
| 7ed14dc749 | |||
| 26736b6bb1 | |||
| 056aae823d | |||
| d1bfdca0c9 | |||
| c0577c29c4 | |||
| a2d1dee0a1 | |||
| d7bcdff29b | |||
| ed71cdeccd | |||
| 729f924de1 | |||
| 4732098731 | |||
| a7d503a8ac | |||
| 063d69eff1 | |||
| 614f446361 | |||
| b62fac9dad | |||
| ff07cb642f | |||
| fba3cf2756 | |||
| 5973c66726 | |||
| b22fc1f9d9 | |||
| 3f48a954d8 | |||
| b5d544df68 | |||
| cd26ba67d4 | |||
| 488390365a | |||
| b7b1129478 | |||
| 0fa9528a3f | |||
| a7b1dcaf95 | |||
| bb5bccbf78 | |||
| ece3ccb958 | |||
| a769cf88f7 | |||
| 8b0f1a0c59 | |||
| 978e748246 | |||
| 32a5cc4728 | |||
| d580f56c0b | |||
| 789458e0d4 | |||
| 8d14d45272 | |||
| 2ec1fa6605 | |||
| f15d682938 | |||
| 21a10a2d14 | |||
| fc02e550bd | |||
| 78637a0689 | |||
| 4ca882fcd4 | |||
| 13ee0eb7f5 | |||
| cb018dfc80 | |||
| 7574dacdb3 | |||
| 0c417b7c32 | |||
| daf845d7bd | |||
| 52792ec89b | |||
| 6dc4a62e8c | |||
| 1cd8ea61ea | |||
| 0c5eb277e4 | |||
| d459a91af3 | |||
| 18722d0031 | |||
| 5119934268 | |||
| 077da23d08 | |||
| 083b4cb17e | |||
| 4316009401 | |||
| 72f3c360b6 | |||
| fcbc195fbe | |||
| af38021d28 | |||
| 5e8cb9fa18 | |||
| 6ef9f6c55e | |||
| e6a3b0ebc0 | |||
| aaae55736f | |||
| 9e586ab634 | |||
| 7ff44d4a50 | |||
| 63abd00ca7 | |||
| 40f2579158 | |||
| ceb2a57feb | |||
| 90e8336797 | |||
| 73ca9c9ed2 | |||
| cc065c2772 | |||
| 028be0fee2 | |||
| d4500da59a | |||
| b6aef6772e | |||
| 49696cecbd | |||
| 83cb52c89c | |||
| e82bae2c4d | |||
| ee2b0204aa | |||
| 1ec7670f6a | |||
| 8ab2e10471 | |||
| 8ff8685ae5 | |||
| f3772cdf82 | |||
| ee2f1cdfd4 | |||
| 0de73a0b3e | |||
| be742e811c | |||
| ca9263fa64 | |||
| 9f619be08d | |||
| 7792254c12 | |||
| fff41f1f27 | |||
| 3e0f9f582e | |||
| 47ba8cfa24 | |||
| 54bb4c8011 | |||
| 71e763263e | |||
| 2ebcda2a55 | |||
| e10db6f7e9 | |||
| 1e041a2957 | |||
| 1631d6f3c0 | |||
| 3d86821258 | |||
| 261bc81554 | |||
| 8f701f43fb | |||
| 4bdb9111dd | |||
| 93e2135223 | |||
| 56cb05aac0 | |||
| 38d5b202c9 | |||
| cfffa9c518 | |||
| 73dbd709d8 | |||
| eef67e2c03 | |||
| e3498f0668 | |||
| f04c147faa | |||
| 33bbd45f1e | |||
| fd91a534c7 | |||
| c2afc357b9 | |||
| 1d3f67f2ce | |||
| 514e4f07f1 | |||
| fbb1c4b2bd | |||
| 63dea599b1 | |||
| 743ba5f050 | |||
| cb180b4195 | |||
| c805b7e29d | |||
| 8f6814450b | |||
| 37e8391cde | |||
| 90234402a7 | |||
| 8ecf603d73 | |||
| af243581ff | |||
| 01afed9ff9 | |||
| 2687bb37fb | |||
| 65b1a10803 | |||
| 9d230ef0d6 | |||
| a03438f2af | |||
| c622e9260f | |||
| 8bf53d6f90 | |||
| 8c30a3b0df | |||
| c61d53eed0 | |||
| 95f7d1d347 | |||
| 72d70bb929 | |||
| 4f67e59692 | |||
| d40d5c8a39 | |||
| 87398ac555 | |||
| de3d5ead42 | |||
| 1e1b571b28 | |||
| f400a7b1b2 | |||
| a0bcb5777f | |||
| f8a625eddb | |||
| 69a2a15b95 | |||
| b9d0596dd7 | |||
| 72af8c193c | |||
| ed8c326856 | |||
| f5bf6b1be6 | |||
| 0e19f8dc69 | |||
| 6049c0bf37 | |||
| a102253f30 | |||
| f71d86f005 | |||
| 70e34ffb76 | |||
| 91aa7b26e6 | |||
| 7d45947fb3 | |||
| 6e174328e8 | |||
| 5fb97fcce4 | |||
| 3d1a450129 | |||
| a58c5aacdf | |||
| d7e165a279 | |||
| a57ee803f1 | |||
| 170a52b09f | |||
| 2be5889d18 | |||
| ca6b574bee | |||
| 57b0172a2d | |||
| 53260ee25d | |||
| 964281322f | |||
| 7c3f483396 | |||
| 59784aa9fe | |||
| 72a2b6d571 | |||
| 5854af0eae | |||
| acd3d3a804 | |||
| 1b8c04a430 | |||
| 378b73f8b8 | |||
| c482a6ab15 | |||
| 2daa429b77 | |||
| 6ebbc15359 | |||
| 9a840d484c | |||
| e89467c9fb | |||
| 0b396c005c | |||
| d05313f95e | |||
| 3f97853011 | |||
| 2a5c4b1edf | |||
| 65a3c6707c | |||
| 942477f0bf | |||
| 1770b3131a | |||
| 41d3ffdab9 | |||
| 78aa6cb62b | |||
| b56b8040e6 | |||
| d1cf98b177 | |||
| 5fc6b3ed17 | |||
| 6e41533fed | |||
| bc76532bd5 | |||
| 62f1dd79bc | |||
| fd3f53e814 | |||
| 798ac7b94c | |||
| eb0c0f7b93 | |||
| f03293f53d | |||
| 7d062387b7 | |||
| 9acf3b18ca | |||
| b41d067c94 | |||
| c8503b3120 | |||
| da03c3b529 | |||
| d48b19e052 | |||
| 6861c67f56 | |||
| c87048bd9f | |||
| 02269f33b7 | |||
| 7f46ae7b97 | |||
| 2ab3566f95 | |||
| 9a504af18e | |||
| 8b50986906 | |||
| f9a222ecea | |||
| 5b5a3d8b5e | |||
| 037cbdd214 | |||
| 0349411e6d | |||
| d7b75e4b9e | |||
| 254f043ab0 | |||
| 5f3e115545 | |||
| fc55c4c72a | |||
| f795577e14 | |||
| f12cee984a | |||
| c3b4572841 | |||
| 40fe159c10 | |||
| ddecc87947 | |||
| 23837266fc | |||
| 3c9ca8c373 | |||
| 7f2a4c2568 | |||
| 2ad647a73c | |||
| 26663e67fd | |||
| 0cfc67c679 | |||
| 7faba5c2f0 | |||
| 1d9250b277 | |||
| 08054c1d6d | |||
| 333872e878 | |||
| 913cd257f4 | |||
| 69f7789c40 | |||
| e79ef1f33a | |||
| fb8f61a5ec | |||
| 9b1f2a1d11 | |||
| 686216fb75 | |||
| f4b83e1a27 | |||
| 97f21b6635 | |||
| 87641a6803 | |||
| 7e4331172a | |||
| a976080d1b | |||
| bcf3bba44e | |||
| 54ac36d424 | |||
| e84c90dbbc | |||
| 4424438658 | |||
| 13d95c8219 | |||
| e119dc4e89 | |||
| b4cdc5a923 | |||
| a82e22b5de | |||
| c894d09d8c | |||
| 585ce07260 | |||
| 8cbbdaa239 | |||
| cd526a254d | |||
| e782a2afa3 | |||
| 565339b1fd | |||
| 493203050a | |||
| 41782c4593 | |||
| 86256a4e74 | |||
| 933a0c9909 | |||
| c8a4d9b88a | |||
| 437128d11b | |||
| c18d09fd22 | |||
| 2a5e5e6a59 | |||
| 68e354752b | |||
| d80b7499fd | |||
| 9c8093eb3e | |||
| aec1c11037 | |||
| e2e9986059 | |||
| d70ffdbc02 | |||
| 0f1f1db3d2 | |||
| 77b91a45cb | |||
| fe6add9396 | |||
| 21cc9c3d8a | |||
| 2d59c4647d | |||
| 1f0c6a6dc9 | |||
| c9b502fb0e | |||
| 330fbaccfc | |||
| 937f370655 | |||
| a8ad3ed26d | |||
| decac58a18 | |||
| 1a91ba59a6 | |||
| 89df43a975 | |||
| ad98706db4 | |||
| 195d1730bd | |||
| db4bd907f8 | |||
| cdd7dbbb2b | |||
| 108f157324 | |||
| 8da39ec8f4 | |||
| 182534288c | |||
| f81b7e5e6f | |||
| 6dda9e532d | |||
| 5e17626fe0 | |||
| abc9c9dcb0 | |||
| f346fcb056 | |||
| c67325ba07 | |||
| a063ae8ce7 | |||
| f9e5535492 | |||
| 015d9c5c4f | |||
| b6d40078d9 | |||
| b8a8f4850a | |||
| 1cc23d789c | |||
| 5cf0bb46a4 | |||
| 16672b3d0c | |||
| f61db81961 | |||
| e2a694115f | |||
| 71cf812d24 | |||
| 8a3d7d5671 | |||
| 2a363598dd | |||
| 05bf6428bc | |||
| e492a44dde | |||
| 44d2e47f96 | |||
| 31459a5d63 | |||
| 8f5db463e7 | |||
| 4e8affafcc | |||
| 2800681bb1 | |||
| b2a9e6f12f | |||
| fbd2c97f87 | |||
| 6c6304a620 | |||
| 0c1d5f6b25 | |||
| 1c26dc0233 | |||
| a163a202e7 | |||
| 4f918f684e | |||
| c142232f4d | |||
| c9bc20aa4d | |||
| 5c0cb3a536 | |||
| 415576d0a0 | |||
| 4f9fad66e4 | |||
| ebd9854980 | |||
| cb2fab64d8 | |||
| f446b49e49 | |||
| 22f5e41058 | |||
| fee5a006f1 | |||
| ef51ee28fd | |||
| cb61345780 | |||
| a18d4e226e | |||
| b09b33eb4c | |||
| 5fedc06d7c | |||
| d8c9f6db33 | |||
| 0af5fa0328 | |||
| b328b72cd5 | |||
| ce2a9d7036 | |||
| 66ae985af5 | |||
| 1828e2849c | |||
| b4f8b0fe4f | |||
| 70656e954f | |||
| 40a4c8d954 | |||
| 7ed787b86a | |||
| 1ee487a2ff | |||
| b76e7ca782 | |||
| ddce1bcd28 |
+1
-1
@@ -23,4 +23,4 @@ indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
indent_size = 4
|
||||
|
||||
+15
-1
@@ -1,6 +1,9 @@
|
||||
module.exports = {
|
||||
plugins: ["matrix-org", "import", "jsdoc"],
|
||||
extends: ["plugin:matrix-org/babel", "plugin:import/typescript"],
|
||||
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/jest", "plugin:import/typescript"],
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
@@ -60,6 +63,17 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
// Disabled tests are a reality for now but as soon as all of the xits are
|
||||
// eliminated, we should enforce this.
|
||||
"jest/no-disabled-tests": "off",
|
||||
// Also treat "oldBackendOnly" as a test function.
|
||||
// Used in some crypto tests.
|
||||
"jest/no-standalone-expect": [
|
||||
"error",
|
||||
{
|
||||
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
)
|
||||
)
|
||||
steps:
|
||||
- uses: tibdex/backport@v2
|
||||
- uses: tibdex/backport@2e217641d82d02ba0603f46b1aeedefb258890ac # v2
|
||||
with:
|
||||
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
|
||||
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
||||
# cypress tests (with access to repo secrets)
|
||||
|
||||
name: matrix-react-sdk Cypress End to End Tests
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build downstream artifacts"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.76.0
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
statuses: write
|
||||
pull-requests: read
|
||||
secrets:
|
||||
# secrets are not automatically shared with called workflows, so share the cypress dashboard key, and the Kiwi login details
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
|
||||
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
|
||||
with:
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
rust-crypto: true
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
# 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 mess:
|
||||
- name: 📥 Download artifact
|
||||
uses: dawidd6/action-download-artifact@bd10f381a96414ce2b13a11bfa89902ba7cea07f # v2.24.3
|
||||
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: static_analysis.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v1
|
||||
uses: matrix-org/netlify-pr-preview@v2
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Build downstream artifacts
|
||||
on:
|
||||
# We only want the Rust Crypto Cypress tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume 4 concurrent runners.
|
||||
# Anyone working on Rust Crypto is able to run the tests locally if required.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
# For now at least, we don't run this or the cypress-tests against pushes
|
||||
# to develop or master.
|
||||
#
|
||||
# Note that if we later choose to do so, we'll need to find a way to stop
|
||||
# the results in Cypress Cloud from clobbering those from the 'develop'
|
||||
# branch of matrix-react-sdk.
|
||||
#
|
||||
#push:
|
||||
# branches: [develop, master]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.76.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
uses: peter-evans/repository-dispatch@26b39ed245ab8f31526069329e112ab2fb224588 # v2
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
||||
@@ -2,23 +2,20 @@ name: Pull Request
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, labeled, unlabeled, synchronize]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_call:
|
||||
inputs:
|
||||
labels:
|
||||
type: string
|
||||
default: "T-Defect,T-Deprecation,T-Enhancement,T-Task"
|
||||
required: false
|
||||
description: "No longer used, uses allchange logic now, will be removed at a later date"
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
|
||||
jobs:
|
||||
changelog:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: matrix-org/allchange@main
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
ghToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
requireLabel: true
|
||||
@@ -42,7 +39,7 @@ jobs:
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- name: Check membership
|
||||
uses: tspascoal/get-user-teams-membership@v2
|
||||
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
|
||||
id: teams
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
|
||||
@@ -20,22 +20,20 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --pure-lockfile"
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
uses: JS-DevTools/npm-publish@v1
|
||||
uses: JS-DevTools/npm-publish@5a85faf05d2ade2d5b6682bfe5359915d5159c6c # v2.2.1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
access: public
|
||||
tag: next
|
||||
ignore-scripts: false
|
||||
|
||||
- name: 🎖️ Add `latest` dist-tag to final releases
|
||||
if: github.event.release.prerelease == false
|
||||
run: |
|
||||
package=$(cat package.json | jq -er .name)
|
||||
npm dist-tag add "$package@$release" latest
|
||||
if: github.event.release.prerelease == false && steps.npm-publish.outputs.id
|
||||
run: npm dist-tag add "$release" latest
|
||||
env:
|
||||
# JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc
|
||||
INPUT_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
release: ${{ steps.npm-publish.outputs.version }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
release: ${{ steps.npm-publish.outputs.id }}
|
||||
|
||||
@@ -11,46 +11,39 @@ jobs:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: _docs
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: 🔨 Install dependencies
|
||||
run: "yarn install --pure-lockfile"
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: 📖 Generate JSDoc
|
||||
run: "yarn gendoc"
|
||||
|
||||
- name: 📋 Copy to temp
|
||||
- name: 🔨 Install symlinks
|
||||
run: |
|
||||
cp -a "./_docs" "$RUNNER_TEMP/"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y symlinks
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: gh-pages
|
||||
|
||||
- name: 🔪 Prepare
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
- name: 📖 Generate docs
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
[ ! -e "$VERSION" ] || rm -r $VERSION
|
||||
cp -r $RUNNER_TEMP/_docs/ $VERSION
|
||||
|
||||
# Add the new directory to the index if it isn't there already
|
||||
if ! grep -q ">Version $VERSION</a>" index.html; then
|
||||
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
|
||||
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
|
||||
fi
|
||||
yarn tpv purge --yes --out _docs --stale --major 10
|
||||
yarn gendoc
|
||||
symlinks -rc _docs
|
||||
|
||||
- name: 🚀 Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
keep_files: true
|
||||
publish_dir: .
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
git add . --all
|
||||
git commit -m "Update docs"
|
||||
git push
|
||||
working-directory: _docs
|
||||
|
||||
npm:
|
||||
name: Publish
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Cypress is done.
|
||||
- uses: Sibz/github-status-action@v1
|
||||
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.3
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.5
|
||||
# workflow_run fails report against the develop commit always, we don't want that for PRs
|
||||
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
|
||||
with:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
coverage_extract_path: coverage
|
||||
extra_args: ${{ inputs.extra_args }}
|
||||
|
||||
- uses: Sibz/github-status-action@v1
|
||||
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -11,6 +11,7 @@ jobs:
|
||||
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
|
||||
prepare:
|
||||
name: Prepare
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
|
||||
@@ -19,7 +20,7 @@ jobs:
|
||||
# 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 mess:
|
||||
- name: 📥 Download artifact
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: tests.yaml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
@@ -35,6 +36,7 @@ jobs:
|
||||
|
||||
sonarqube:
|
||||
name: 🩻 SonarQube
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
needs: prepare
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||
secrets:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Static Analysis
|
||||
on:
|
||||
pull_request: {}
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
concurrency:
|
||||
@@ -63,7 +65,14 @@ jobs:
|
||||
run: "yarn install"
|
||||
|
||||
- name: Generate Docs
|
||||
run: "yarn run gendoc"
|
||||
run: "yarn run gendoc --treatWarningsAsErrors"
|
||||
|
||||
# Upload artifact duplicates symlink contents so we do this to save 75% space
|
||||
- name: Flatten symlink and write _redirects
|
||||
run: |
|
||||
find _docs -mindepth 1 -maxdepth 1 ! -type f ! -name stable -printf '/%f/* /stable/:splat\n' > _docs/_redirects
|
||||
find _docs -mindepth 1 -maxdepth 1 -type l -delete
|
||||
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
+50
-13
@@ -1,11 +1,15 @@
|
||||
name: Tests
|
||||
on:
|
||||
pull_request: {}
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
|
||||
jobs:
|
||||
jest:
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
|
||||
@@ -14,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [browserify, integ, unit]
|
||||
node: [16, 18, latest]
|
||||
node: [18, latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
@@ -34,28 +38,61 @@ jobs:
|
||||
|
||||
- name: Get number of CPU cores
|
||||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@v1
|
||||
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
|
||||
|
||||
- name: Run tests with coverage and metrics
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
- name: Run tests
|
||||
run: |
|
||||
yarn coverage --ci --reporters github-actions '--reporters=<rootDir>/spec/slowReporter.js' --max-workers ${{ steps.cpu-cores.outputs.count }} ./spec/${{ matrix.specs }}
|
||||
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
yarn test \
|
||||
--coverage=${{ env.ENABLE_COVERAGE }} \
|
||||
--ci \
|
||||
--max-workers ${{ steps.cpu-cores.outputs.count }} \
|
||||
./spec/${{ matrix.specs }}
|
||||
env:
|
||||
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: github.ref != 'refs/heads/develop'
|
||||
run: |
|
||||
yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }} ./spec/${{ matrix.specs }}
|
||||
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
env:
|
||||
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
|
||||
# tell jest to use coloured output
|
||||
FORCE_COLOR: true
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage
|
||||
path: |
|
||||
coverage
|
||||
!coverage/lcov-report
|
||||
|
||||
matrix-react-sdk:
|
||||
name: Downstream test matrix-react-sdk
|
||||
if: github.event_name == 'merge_group'
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/tests.yml@develop
|
||||
with:
|
||||
disable_coverage: true
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
|
||||
# Hook for branch protection to skip downstream testing outside of merge queues
|
||||
# and skip sonarcloud coverage within merge queues
|
||||
downstream:
|
||||
name: Downstream tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs:
|
||||
- matrix-react-sdk
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
description: SonarCloud skipped
|
||||
context: SonarCloud Code Analysis
|
||||
sha: ${{ github.sha }}
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- if: needs.matrix-react-sdk.result != 'skipped' && needs.matrix-react-sdk.result != 'success'
|
||||
run: exit 1
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
T-Task
|
||||
|
||||
- name: Enable automerge
|
||||
uses: peter-evans/enable-pull-request-automerge@v2
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
|
||||
@@ -19,3 +19,4 @@ out
|
||||
|
||||
.vscode
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -24,3 +24,6 @@ out
|
||||
|
||||
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
|
||||
/CHANGELOG.md
|
||||
|
||||
# This file is also autogenerated
|
||||
/spec/test-utils/test-data/index.ts
|
||||
|
||||
+278
@@ -1,3 +1,281 @@
|
||||
Changes in [27.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.2.0-rc.1) (2023-08-08)
|
||||
============================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* Allow knocking rooms ([\#3647](https://github.com/matrix-org/matrix-js-sdk/pull/3647)). Contributed by @charlynguyen.
|
||||
* Bump pagination limit to account for threaded events ([\#3638](https://github.com/matrix-org/matrix-js-sdk/pull/3638)).
|
||||
* ElementR: Add `CryptoApi.findVerificationRequestDMInProgress` ([\#3601](https://github.com/matrix-org/matrix-js-sdk/pull/3601)). Contributed by @florianduros.
|
||||
* Export more into the public interface ([\#3614](https://github.com/matrix-org/matrix-js-sdk/pull/3614)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix wrong handling of encrypted rooms when loading them from sync accumulator ([\#3640](https://github.com/matrix-org/matrix-js-sdk/pull/3640)). Fixes vector-im/element-web#25803.
|
||||
* Skip processing thread roots and fetching threads list when support is disabled ([\#3642](https://github.com/matrix-org/matrix-js-sdk/pull/3642)).
|
||||
* Ensure we don't overinflate the total notification count ([\#3634](https://github.com/matrix-org/matrix-js-sdk/pull/3634)). Fixes vector-im/element-web#25803.
|
||||
|
||||
Changes in [27.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.1.0) (2023-08-01)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
|
||||
## ✨ Features
|
||||
* ElementR: Add `CryptoApi.getCrossSigningKeyId` ([\#3619](https://github.com/matrix-org/matrix-js-sdk/pull/3619)). Contributed by @florianduros.
|
||||
* ElementR: Stub `CheckOwnCrossSigningTrust`, import cross signing keys and verify local device in `bootstrapCrossSigning` ([\#3608](https://github.com/matrix-org/matrix-js-sdk/pull/3608)). Contributed by @florianduros.
|
||||
* Specify /preview_url requests as low priority ([\#3609](https://github.com/matrix-org/matrix-js-sdk/pull/3609)). Fixes vector-im/element-web#7292.
|
||||
* Element-R: support for displaying QR codes during verification ([\#3588](https://github.com/matrix-org/matrix-js-sdk/pull/3588)). Fixes vector-im/crypto-internal#124.
|
||||
* Add support for scanning QR codes during verification, with Rust crypto ([\#3565](https://github.com/matrix-org/matrix-js-sdk/pull/3565)).
|
||||
* Add methods to influence set_presence on /sync API calls ([\#3578](https://github.com/matrix-org/matrix-js-sdk/pull/3578)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix threads ending up with chunks of their timelines missing ([\#3618](https://github.com/matrix-org/matrix-js-sdk/pull/3618)). Fixes vector-im/element-web#24466.
|
||||
* Ensure we do not clobber a newer RR with an older unthreaded one ([\#3617](https://github.com/matrix-org/matrix-js-sdk/pull/3617)). Fixes vector-im/element-web#25806.
|
||||
* Fix registration check your emails stage regression ([\#3616](https://github.com/matrix-org/matrix-js-sdk/pull/3616)).
|
||||
* Fix how `Room::eventShouldLiveIn` handles replies to unknown parents ([\#3615](https://github.com/matrix-org/matrix-js-sdk/pull/3615)). Fixes vector-im/element-web#22603.
|
||||
* Only send threaded read receipts if threads support is enabled ([\#3612](https://github.com/matrix-org/matrix-js-sdk/pull/3612)).
|
||||
* ElementR: Fix `userId` parameter usage in `CryptoApi#getVerificationRequestsToDeviceInProgress` ([\#3611](https://github.com/matrix-org/matrix-js-sdk/pull/3611)). Contributed by @florianduros.
|
||||
* Fix edge cases around non-thread relations to thread roots and read receipts ([\#3607](https://github.com/matrix-org/matrix-js-sdk/pull/3607)).
|
||||
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
|
||||
* Export typed event emitter key types ([\#3597](https://github.com/matrix-org/matrix-js-sdk/pull/3597)). Fixes #3506.
|
||||
* Element-R: ensure that `userHasCrossSigningKeys` uses up-to-date data ([\#3599](https://github.com/matrix-org/matrix-js-sdk/pull/3599)). Fixes vector-im/element-web#25773.
|
||||
* Fix sending `auth: null` due to broken types around UIA ([\#3594](https://github.com/matrix-org/matrix-js-sdk/pull/3594)).
|
||||
|
||||
Changes in [27.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v27.0.0) (2023-07-18)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Drop support for Node 16 ([\#3533](https://github.com/matrix-org/matrix-js-sdk/pull/3533)).
|
||||
* Improve types around login, registration, UIA and identity servers ([\#3537](https://github.com/matrix-org/matrix-js-sdk/pull/3537)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* **The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. (#3189)**
|
||||
* Simplify `MatrixClient::setPowerLevel` API ([\#3570](https://github.com/matrix-org/matrix-js-sdk/pull/3570)). Fixes vector-im/element-web#13900 and #1844.
|
||||
* Deprecate `VerificationRequest.getQRCodeBytes` and replace it with the asynchronous `generateQRCode`. ([\#3562](https://github.com/matrix-org/matrix-js-sdk/pull/3562)).
|
||||
* Deprecate `VerificationRequest.beginKeyVerification()` in favour of `VerificationRequest.startVerification()`. ([\#3528](https://github.com/matrix-org/matrix-js-sdk/pull/3528)).
|
||||
* Deprecate `Crypto.VerificationRequest` application event, replacing it with `Crypto.VerificationRequestReceived`. ([\#3514](https://github.com/matrix-org/matrix-js-sdk/pull/3514)).
|
||||
|
||||
## ✨ Features
|
||||
* Throw saner error when peeking has its room pulled out from under it ([\#3577](https://github.com/matrix-org/matrix-js-sdk/pull/3577)). Fixes vector-im/element-web#18679.
|
||||
* OIDC: Log in ([\#3554](https://github.com/matrix-org/matrix-js-sdk/pull/3554)). Contributed by @kerryarchibald.
|
||||
* Prevent threads code from making identical simultaneous API hits ([\#3541](https://github.com/matrix-org/matrix-js-sdk/pull/3541)). Fixes vector-im/element-web#25395.
|
||||
* Update IUnsigned type to be extensible ([\#3547](https://github.com/matrix-org/matrix-js-sdk/pull/3547)).
|
||||
* add stop() api to BackupManager for clean shutdown ([\#3553](https://github.com/matrix-org/matrix-js-sdk/pull/3553)).
|
||||
* Log the message ID of any undecryptable to-device messages ([\#3543](https://github.com/matrix-org/matrix-js-sdk/pull/3543)).
|
||||
* Ignore thread relations on state events for consistency with edits ([\#3540](https://github.com/matrix-org/matrix-js-sdk/pull/3540)).
|
||||
* OIDC: validate id token ([\#3531](https://github.com/matrix-org/matrix-js-sdk/pull/3531)). Contributed by @kerryarchibald.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix read receipt sending behaviour around thread roots ([\#3600](https://github.com/matrix-org/matrix-js-sdk/pull/3600)).
|
||||
* Fix `TypedEventEmitter::removeAllListeners(void)` not working ([\#3561](https://github.com/matrix-org/matrix-js-sdk/pull/3561)).
|
||||
* Don't allow Olm unwedging rate-limiting to race ([\#3549](https://github.com/matrix-org/matrix-js-sdk/pull/3549)). Fixes vector-im/element-web#25716.
|
||||
* Fix an instance of failed to decrypt error when an in flight `/keys/query` fails. ([\#3486](https://github.com/matrix-org/matrix-js-sdk/pull/3486)).
|
||||
* Use the right anchor emoji for SAS verification ([\#3534](https://github.com/matrix-org/matrix-js-sdk/pull/3534)).
|
||||
* fix a bug which caused the wrong emoji to be shown during SAS device verification. ([\#3523](https://github.com/matrix-org/matrix-js-sdk/pull/3523)).
|
||||
|
||||
Changes in [26.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0) (2023-07-04)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. ([\#3189](https://github.com/matrix-org/matrix-js-sdk/issues/3189)).
|
||||
* ElementR: Add `CryptoApi#bootstrapSecretStorage` ([\#3483](https://github.com/matrix-org/matrix-js-sdk/pull/3483)). Contributed by @florianduros.
|
||||
* Deprecate `MatrixClient.findVerificationRequestDMInProgress`, `MatrixClient.getVerificationRequestsToDeviceInProgress`, and `MatrixClient.requestVerification`, in favour of methods in `CryptoApi`. ([\#3474](https://github.com/matrix-org/matrix-js-sdk/pull/3474)).
|
||||
* Introduce a new `Crypto.VerificationRequest` interface, and deprecate direct access to the old `VerificationRequest` class. Also deprecate some related classes that were exported from `src/crypto/verification/request/VerificationRequest` ([\#3449](https://github.com/matrix-org/matrix-js-sdk/pull/3449)).
|
||||
|
||||
## ✨ Features
|
||||
* OIDC: navigate to authorization endpoint ([\#3499](https://github.com/matrix-org/matrix-js-sdk/pull/3499)). Contributed by @kerryarchibald.
|
||||
* Support for interactive device verification in Element-R. ([\#3505](https://github.com/matrix-org/matrix-js-sdk/pull/3505)).
|
||||
* Support for interactive device verification in Element-R. ([\#3508](https://github.com/matrix-org/matrix-js-sdk/pull/3508)).
|
||||
* Support for interactive device verification in Element-R. ([\#3490](https://github.com/matrix-org/matrix-js-sdk/pull/3490)). Fixes vector-im/element-web#25316.
|
||||
* Element-R: Store cross signing keys in secret storage ([\#3498](https://github.com/matrix-org/matrix-js-sdk/pull/3498)). Contributed by @florianduros.
|
||||
* OIDC: add dynamic client registration util function ([\#3481](https://github.com/matrix-org/matrix-js-sdk/pull/3481)). Contributed by @kerryarchibald.
|
||||
* Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room ([\#3493](https://github.com/matrix-org/matrix-js-sdk/pull/3493)).
|
||||
* ElementR: Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation ([\#3472](https://github.com/matrix-org/matrix-js-sdk/pull/3472)). Contributed by @florianduros.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Aggregate relations regardless of whether event fits into the timeline ([\#3496](https://github.com/matrix-org/matrix-js-sdk/pull/3496)). Fixes vector-im/element-web#25596.
|
||||
* Fix bug where switching media caused media in subsequent calls to fail ([\#3489](https://github.com/matrix-org/matrix-js-sdk/pull/3489)).
|
||||
* Fix: remove polls from room state on redaction ([\#3475](https://github.com/matrix-org/matrix-js-sdk/pull/3475)). Fixes vector-im/element-web#25573. Contributed by @kerryarchibald.
|
||||
* Fix export type `GeneratedSecretStorageKey` ([\#3479](https://github.com/matrix-org/matrix-js-sdk/pull/3479)). Contributed by @florianduros.
|
||||
* Close IDB database before deleting it to prevent spurious unexpected close errors ([\#3478](https://github.com/matrix-org/matrix-js-sdk/pull/3478)). Fixes vector-im/element-web#25597.
|
||||
|
||||
Changes in [26.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.1.0) (2023-06-20)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Introduce a new `Crypto.Verifier` interface, and deprecate direct access to `VerificationBase`, `SAS` and `ReciprocateQRCode` ([\#3414](https://github.com/matrix-org/matrix-js-sdk/pull/3414)).
|
||||
|
||||
## ✨ Features
|
||||
* Add `rust-crypto#isCrossSigningReady` implementation ([\#3462](https://github.com/matrix-org/matrix-js-sdk/pull/3462)). Contributed by @florianduros.
|
||||
* OIDC: Validate `m.authentication` configuration ([\#3419](https://github.com/matrix-org/matrix-js-sdk/pull/3419)). Contributed by @kerryarchibald.
|
||||
* ElementR: Add `CryptoApi.getCrossSigningStatus` ([\#3452](https://github.com/matrix-org/matrix-js-sdk/pull/3452)). Contributed by @florianduros.
|
||||
* Extend stats summary with call device and user count based on room state ([\#3424](https://github.com/matrix-org/matrix-js-sdk/pull/3424)). Contributed by @toger5.
|
||||
* Update MSC3912 implementation to use `with_rel_type` instead of `with_relations` ([\#3420](https://github.com/matrix-org/matrix-js-sdk/pull/3420)).
|
||||
* Export thread-related types from SDK ([\#3447](https://github.com/matrix-org/matrix-js-sdk/pull/3447)). Contributed by @stas-demydiuk.
|
||||
* Use correct /v3 prefix for /refresh ([\#3016](https://github.com/matrix-org/matrix-js-sdk/pull/3016)). Contributed by @davidisaaclee.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix thread list being ordered based on all updates ([\#3458](https://github.com/matrix-org/matrix-js-sdk/pull/3458)). Fixes vector-im/element-web#25522.
|
||||
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
|
||||
* use cli.canSupport to determine intentional mentions support ([\#3445](https://github.com/matrix-org/matrix-js-sdk/pull/3445)). Fixes vector-im/element-web#25497. Contributed by @kerryarchibald.
|
||||
* Make sliding sync linearize processing of sync requests ([\#3442](https://github.com/matrix-org/matrix-js-sdk/pull/3442)).
|
||||
* Fix edge cases around 2nd order relations and threads ([\#3437](https://github.com/matrix-org/matrix-js-sdk/pull/3437)).
|
||||
|
||||
Changes in [26.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.1) (2023-06-09)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
|
||||
|
||||
Changes in [26.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.0) (2023-06-06)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Ensure we do not add relations to the wrong timeline ([\#3427](https://github.com/matrix-org/matrix-js-sdk/pull/3427)). Fixes vector-im/element-web#25450 and vector-im/element-web#25494.
|
||||
* Deprecate `QrCodeEvent`, `SasEvent` and `VerificationEvent` ([\#3386](https://github.com/matrix-org/matrix-js-sdk/pull/3386)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Move crypto classes into a separate namespace ([\#3385](https://github.com/matrix-org/matrix-js-sdk/pull/3385)).
|
||||
|
||||
## ✨ Features
|
||||
* Mention deno support in the README ([\#3417](https://github.com/matrix-org/matrix-js-sdk/pull/3417)). Contributed by @sigmaSd.
|
||||
* Mark room version 10 as safe ([\#3425](https://github.com/matrix-org/matrix-js-sdk/pull/3425)).
|
||||
* Prioritise entirely supported flows for UIA ([\#3402](https://github.com/matrix-org/matrix-js-sdk/pull/3402)).
|
||||
* Add methods to terminate idb worker ([\#3362](https://github.com/matrix-org/matrix-js-sdk/pull/3362)).
|
||||
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
|
||||
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Correctly accumulate sync summaries. ([\#3366](https://github.com/matrix-org/matrix-js-sdk/pull/3366)). Fixes vector-im/element-web#23345.
|
||||
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
|
||||
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
|
||||
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
|
||||
* Remove spec non-compliant extended glob format ([\#3423](https://github.com/matrix-org/matrix-js-sdk/pull/3423)). Fixes vector-im/element-web#25474.
|
||||
* Fix bug where original event was inserted into timeline instead of the edit event ([\#3398](https://github.com/matrix-org/matrix-js-sdk/pull/3398)). Contributed by @andybalaam.
|
||||
* Only add a local receipt if it's after an existing receipt ([\#3399](https://github.com/matrix-org/matrix-js-sdk/pull/3399)). Contributed by @andybalaam.
|
||||
* Attempt a potential workaround for stuck notifs ([\#3384](https://github.com/matrix-org/matrix-js-sdk/pull/3384)). Fixes vector-im/element-web#25406. Contributed by @andybalaam.
|
||||
* Fix verification bug with `pendingEventOrdering: "chronological"` ([\#3382](https://github.com/matrix-org/matrix-js-sdk/pull/3382)).
|
||||
|
||||
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Rebuild to fix packaging glitch in 25.1.0. Fixes #3363
|
||||
|
||||
Changes in [25.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.0) (2023-05-09)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate MatrixClient::resolveRoomAlias ([\#3316](https://github.com/matrix-org/matrix-js-sdk/pull/3316)).
|
||||
|
||||
## ✨ Features
|
||||
* add client method to remove pusher ([\#3324](https://github.com/matrix-org/matrix-js-sdk/pull/3324)). Contributed by @kerryarchibald.
|
||||
* Implement MSC 3981 ([\#3248](https://github.com/matrix-org/matrix-js-sdk/pull/3248)). Fixes vector-im/element-web#25021. Contributed by @justjanne.
|
||||
* Added `Room.getLastLiveEvent` and `Room.getLastThread`. Deprecated `Room.lastThread` in favour of `Room.getLastThread`. ([\#3321](https://github.com/matrix-org/matrix-js-sdk/pull/3321)).
|
||||
* Element-R: wire up device lists ([\#3272](https://github.com/matrix-org/matrix-js-sdk/pull/3272)). Contributed by @florianduros.
|
||||
* Node 20 support ([\#3302](https://github.com/matrix-org/matrix-js-sdk/pull/3302)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix racing between one-time-keys processing and sync ([\#3327](https://github.com/matrix-org/matrix-js-sdk/pull/3327)). Fixes vector-im/element-web#25214. Contributed by @florianduros.
|
||||
* Fix lack of media when a user reconnects ([\#3318](https://github.com/matrix-org/matrix-js-sdk/pull/3318)).
|
||||
* Fix TimelineWindow getEvents exploding if no neigbouring timeline ([\#3285](https://github.com/matrix-org/matrix-js-sdk/pull/3285)). Fixes vector-im/element-web#25104.
|
||||
|
||||
Changes in [25.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0) (2023-04-25)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Change `Store.save()` to return a `Promise` ([\#3221](https://github.com/matrix-org/matrix-js-sdk/pull/3221)). Contributed by @texuf.
|
||||
|
||||
## ✨ Features
|
||||
* Add typedoc-plugin-mdn-links ([\#3292](https://github.com/matrix-org/matrix-js-sdk/pull/3292)).
|
||||
* Annotate events with executed push rule ([\#3284](https://github.com/matrix-org/matrix-js-sdk/pull/3284)). Contributed by @kerryarchibald.
|
||||
* Element-R: pass device list change notifications into rust crypto-sdk ([\#3254](https://github.com/matrix-org/matrix-js-sdk/pull/3254)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
|
||||
* Support for MSC3882 revision 1 ([\#3228](https://github.com/matrix-org/matrix-js-sdk/pull/3228)). Contributed by @hughns.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix screen sharing on Firefox 113 ([\#3282](https://github.com/matrix-org/matrix-js-sdk/pull/3282)). Contributed by @tulir.
|
||||
* Retry processing potential poll events after decryption ([\#3246](https://github.com/matrix-org/matrix-js-sdk/pull/3246)). Fixes vector-im/element-web#24568.
|
||||
* Element-R: handle events which arrive before their keys ([\#3230](https://github.com/matrix-org/matrix-js-sdk/pull/3230)). Fixes vector-im/element-web#24489.
|
||||
|
||||
Changes in [24.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.1.0) (2023-04-11)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Allow via_servers property in findPredecessor (update to MSC3946) ([\#3240](https://github.com/matrix-org/matrix-js-sdk/pull/3240)). Contributed by @andybalaam.
|
||||
* Fire `closed` event when IndexedDB closes unexpectedly ([\#3218](https://github.com/matrix-org/matrix-js-sdk/pull/3218)).
|
||||
* Implement MSC3952: intentional mentions ([\#3092](https://github.com/matrix-org/matrix-js-sdk/pull/3092)). Fixes vector-im/element-web#24376.
|
||||
* Send one time key count and unused fallback keys for rust-crypto ([\#3215](https://github.com/matrix-org/matrix-js-sdk/pull/3215)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
|
||||
* Improve `processBeaconEvents` hotpath ([\#3200](https://github.com/matrix-org/matrix-js-sdk/pull/3200)).
|
||||
* Implement MSC3966: a push rule condition to check if an array contains a value ([\#3180](https://github.com/matrix-org/matrix-js-sdk/pull/3180)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* indexddb-local-backend - return the current sync to database promise … ([\#3222](https://github.com/matrix-org/matrix-js-sdk/pull/3222)). Contributed by @texuf.
|
||||
* Revert "Add the call object to Call events" ([\#3236](https://github.com/matrix-org/matrix-js-sdk/pull/3236)).
|
||||
* Handle group call redaction ([\#3231](https://github.com/matrix-org/matrix-js-sdk/pull/3231)). Fixes vector-im/voip-internal#128.
|
||||
* Stop doing O(n^2) work to find event's home (`eventShouldLiveIn`) ([\#3227](https://github.com/matrix-org/matrix-js-sdk/pull/3227)). Contributed by @jryans.
|
||||
* Fix bug where video would not unmute if it started muted ([\#3213](https://github.com/matrix-org/matrix-js-sdk/pull/3213)). Fixes vector-im/element-call#925.
|
||||
* Fixes to event encryption in the Rust Crypto implementation ([\#3202](https://github.com/matrix-org/matrix-js-sdk/pull/3202)).
|
||||
|
||||
Changes in [24.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.0.0) (2023-03-28)
|
||||
==================================================================================================
|
||||
|
||||
## 🔒 Security
|
||||
* Fixes for [CVE-2023-28427](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE-2023-28427) / GHSA-mwq8-fjpf-c2gr
|
||||
|
||||
Changes in [23.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.5.0) (2023-03-15)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Implement MSC3758: a push rule condition to match event properties exactly ([\#3179](https://github.com/matrix-org/matrix-js-sdk/pull/3179)).
|
||||
* Enable group calls without video and audio track by configuration of MatrixClient ([\#3162](https://github.com/matrix-org/matrix-js-sdk/pull/3162)). Contributed by @EnricoSchw.
|
||||
* Updates to protocol used for Sign in with QR code ([\#3155](https://github.com/matrix-org/matrix-js-sdk/pull/3155)). Contributed by @hughns.
|
||||
* Implement MSC3873 to handle escaped dots in push rule keys ([\#3134](https://github.com/matrix-org/matrix-js-sdk/pull/3134)). Fixes undefined/matrix-js-sdk#1454.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix spec compliance issue around encrypted `m.relates_to` ([\#3178](https://github.com/matrix-org/matrix-js-sdk/pull/3178)).
|
||||
* Fix reactions in threads sometimes causing stuck notifications ([\#3146](https://github.com/matrix-org/matrix-js-sdk/pull/3146)). Fixes vector-im/element-web#24000. Contributed by @justjanne.
|
||||
|
||||
Changes in [23.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.4.0) (2023-02-28)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Add easy way to determine if the decryption failure is due to "DecryptionError: The sender has disabled encrypting to unverified devices." ([\#3167](https://github.com/matrix-org/matrix-js-sdk/pull/3167)). Contributed by @florianduros.
|
||||
* Polls: expose end event id on poll model ([\#3160](https://github.com/matrix-org/matrix-js-sdk/pull/3160)). Contributed by @kerryarchibald.
|
||||
* Polls: count undecryptable poll relations ([\#3163](https://github.com/matrix-org/matrix-js-sdk/pull/3163)). Contributed by @kerryarchibald.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Better type guard parseTopicContent ([\#3165](https://github.com/matrix-org/matrix-js-sdk/pull/3165)). Fixes matrix-org/element-web-rageshakes#20177 and matrix-org/element-web-rageshakes#20178.
|
||||
* Fix a bug where events in encrypted rooms would sometimes erroneously increment the total unread counter after being processed locally. ([\#3130](https://github.com/matrix-org/matrix-js-sdk/pull/3130)). Fixes vector-im/element-web#24448. Contributed by @Half-Shot.
|
||||
* Stop the ICE disconnected timer on call terminate ([\#3147](https://github.com/matrix-org/matrix-js-sdk/pull/3147)).
|
||||
* Clear notifications when we can infer read status from receipts ([\#3139](https://github.com/matrix-org/matrix-js-sdk/pull/3139)). Fixes vector-im/element-web#23991.
|
||||
* Messages sent out of order after one message fails ([\#3131](https://github.com/matrix-org/matrix-js-sdk/pull/3131)). Fixes vector-im/element-web#22885 and vector-im/element-web#18942. Contributed by @justjanne.
|
||||
|
||||
Changes in [23.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.3.0) (2023-02-14)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Element-R: implement encryption of outgoing events ([\#3122](https://github.com/matrix-org/matrix-js-sdk/pull/3122)).
|
||||
* Poll model - page /relations results ([\#3073](https://github.com/matrix-org/matrix-js-sdk/pull/3073)). Contributed by @kerryarchibald.
|
||||
* Poll model - validate end events ([\#3072](https://github.com/matrix-org/matrix-js-sdk/pull/3072)). Contributed by @kerryarchibald.
|
||||
* Handle optional last_known_event_id property in m.predecessor ([\#3119](https://github.com/matrix-org/matrix-js-sdk/pull/3119)). Contributed by @andybalaam.
|
||||
* Add support for stable identifier for fixed MAC in SAS verification ([\#3101](https://github.com/matrix-org/matrix-js-sdk/pull/3101)).
|
||||
* Provide eventId as well as roomId from Room.findPredecessor ([\#3095](https://github.com/matrix-org/matrix-js-sdk/pull/3095)). Contributed by @andybalaam.
|
||||
* MSC3946 Dynamic room predecessors ([\#3042](https://github.com/matrix-org/matrix-js-sdk/pull/3042)). Contributed by @andybalaam.
|
||||
* Poll model ([\#3036](https://github.com/matrix-org/matrix-js-sdk/pull/3036)). Contributed by @kerryarchibald.
|
||||
* Remove video tracks on video mute without renegotiating ([\#3091](https://github.com/matrix-org/matrix-js-sdk/pull/3091)).
|
||||
* Introduces a backwards-compatible API change. `MegolmEncrypter#prepareToEncrypt`'s return type has changed from `void` to `() => void`. ([\#3035](https://github.com/matrix-org/matrix-js-sdk/pull/3035)). Contributed by @clarkf.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Stop the ICE disconnected timer on call terminate ([\#3147](https://github.com/matrix-org/matrix-js-sdk/pull/3147)).
|
||||
* Clear notifications when we can infer read status from receipts ([\#3139](https://github.com/matrix-org/matrix-js-sdk/pull/3139)). Fixes vector-im/element-web#23991.
|
||||
* Messages sent out of order after one message fails ([\#3131](https://github.com/matrix-org/matrix-js-sdk/pull/3131)). Fixes vector-im/element-web#22885 and vector-im/element-web#18942. Contributed by @justjanne.
|
||||
* Element-R: fix a bug which prevented encryption working after a reload ([\#3126](https://github.com/matrix-org/matrix-js-sdk/pull/3126)).
|
||||
* Element-R: Fix invite processing ([\#3121](https://github.com/matrix-org/matrix-js-sdk/pull/3121)).
|
||||
* Don't throw with no `opponentDeviceInfo` ([\#3107](https://github.com/matrix-org/matrix-js-sdk/pull/3107)).
|
||||
* Remove flaky megolm test ([\#3098](https://github.com/matrix-org/matrix-js-sdk/pull/3098)). Contributed by @clarkf.
|
||||
* Fix "verifyLinks" functionality of getRoomUpgradeHistory ([\#3089](https://github.com/matrix-org/matrix-js-sdk/pull/3089)). Contributed by @andybalaam.
|
||||
|
||||
Changes in [23.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.2.0) (2023-01-31)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ endpoints from before Matrix 1.1, for example.
|
||||
|
||||
## In a browser
|
||||
|
||||
### Note, the browserify build has been deprecated. Please use a bundler like webpack or vite instead.
|
||||
|
||||
Download the browser version from
|
||||
https://github.com/matrix-org/matrix-js-sdk/releases/latest and add that as a
|
||||
`<script>` to your page. There will be a global variable `matrixcs`
|
||||
@@ -55,6 +57,8 @@ client.publicRooms(function (err, data) {
|
||||
See below for how to include libolm to enable end-to-end-encryption. Please check
|
||||
[the Node.js terminal app](examples/node) for a more complex example.
|
||||
|
||||
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
|
||||
|
||||
To start the client:
|
||||
|
||||
```javascript
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Copyright 2023 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 type { Config } from "jest";
|
||||
import { env } from "process";
|
||||
|
||||
const config: Config = {
|
||||
testEnvironment: "node",
|
||||
testMatch: ["<rootDir>/spec/**/*.spec.{js,ts}"],
|
||||
setupFilesAfterEnv: ["<rootDir>/spec/setupTests.ts"],
|
||||
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
|
||||
coverageReporters: ["text-summary", "lcov"],
|
||||
testResultsProcessor: "@casualbot/jest-sonar-reporter",
|
||||
|
||||
// Always print out a summary if there are any failing tests. Normally
|
||||
// a summary is only printed if there are more than 20 test *suites*.
|
||||
reporters: [["default", { summaryThreshold: 0 }]],
|
||||
};
|
||||
|
||||
// if we're running under GHA, enable the GHA reporter
|
||||
if (env["GITHUB_ACTIONS"] !== undefined) {
|
||||
const reporters: Config["reporters"] = [
|
||||
["github-actions", { silent: false }],
|
||||
// as above: always show a summary if there were any failing tests.
|
||||
["summary", { summaryThreshold: 0 }],
|
||||
];
|
||||
|
||||
// if we're running against the develop branch, also enable the slow test reporter
|
||||
if (env["GITHUB_REF"] == "refs/heads/develop") {
|
||||
reporters.push("<rootDir>/spec/slowReporter.js");
|
||||
}
|
||||
config.reporters = reporters;
|
||||
}
|
||||
|
||||
export default config;
|
||||
+48
-35
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "23.2.0",
|
||||
"version": "27.2.0-rc.1",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "yarn build",
|
||||
@@ -14,7 +14,7 @@
|
||||
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"build:compile-browser": "mkdir dist && browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
|
||||
"build:compile-browser": "mkdir dist && BROWSERIFYSWAP_ENV='no-rust-crypto' browserify -d src/browser-index.ts -p [ tsify -p ./tsconfig-build.json ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
|
||||
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
|
||||
"gendoc": "typedoc",
|
||||
"lint": "yarn lint:types && yarn lint:js",
|
||||
@@ -33,7 +33,7 @@
|
||||
"matrix-org"
|
||||
],
|
||||
"main": "./lib/index.js",
|
||||
"browser": "./src/browser-index.ts",
|
||||
"browser": "./lib/browser-index.js",
|
||||
"matrix_src_main": "./src/index.ts",
|
||||
"matrix_src_browser": "./src/browser-index.ts",
|
||||
"matrix_lib_main": "./lib/index.js",
|
||||
@@ -55,13 +55,15 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.2",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"loglevel": "^1.7.1",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.0.0",
|
||||
"matrix-widget-api": "^1.5.0",
|
||||
"oidc-client-ts": "^2.2.4",
|
||||
"p-retry": "4",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6",
|
||||
@@ -84,65 +86,76 @@
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/domexception": "^4.0.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "7",
|
||||
"@types/uuid": "9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"allchange": "^1.0.6",
|
||||
"babel-jest": "^29.0.0",
|
||||
"babelify": "^10.0.0",
|
||||
"better-docs": "^2.4.0-beta.9",
|
||||
"browserify": "^17.0.0",
|
||||
"docdash": "^2.0.0",
|
||||
"browserify-swap": "^0.2.2",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.31.0",
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^39.6.4",
|
||||
"eslint-plugin-matrix-org": "^0.9.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^46.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^45.0.0",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"prettier": "2.8.8",
|
||||
"rimraf": "^5.0.0",
|
||||
"terser": "^5.5.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.23.20",
|
||||
"typedoc-plugin-missing-exports": "^1.0.0",
|
||||
"typescript": "^4.5.3"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testMatch": [
|
||||
"<rootDir>/spec/**/*.spec.{js,ts}"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/spec/setupTests.ts"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/src/**/*.{js,ts}"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text-summary",
|
||||
"lcov"
|
||||
],
|
||||
"testResultsProcessor": "@casualbot/jest-sonar-reporter"
|
||||
"typedoc": "^0.24.0",
|
||||
"typedoc-plugin-coverage": "^2.1.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
"typedoc-plugin-missing-exports": "^2.0.0",
|
||||
"typedoc-plugin-versions": "^0.2.3",
|
||||
"typedoc-plugin-versions-cli": "^0.1.12",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"@casualbot/jest-sonar-reporter": {
|
||||
"outputDirectory": "coverage",
|
||||
"outputName": "jest-sonar-report.xml",
|
||||
"relativePaths": true
|
||||
},
|
||||
"browserify": {
|
||||
"transform": [
|
||||
"browserify-swap",
|
||||
[
|
||||
"babelify",
|
||||
{
|
||||
"sourceMaps": "inline",
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-typescript"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"browserify-swap": {
|
||||
"no-rust-crypto": {
|
||||
"src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts"
|
||||
}
|
||||
},
|
||||
"typings": "./lib/index.d.ts"
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
|
||||
|
||||
if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
|
||||
# When merging to develop, we need revert the `main` and `typings` fields if we adjusted them previously.
|
||||
for i in main typings
|
||||
for i in main typings browser
|
||||
do
|
||||
# If a `lib` prefixed value is present, it means we adjusted the field
|
||||
# earlier at publish time, so we should revert it now.
|
||||
|
||||
+3
-3
@@ -130,7 +130,7 @@ fi
|
||||
# global cache here to ensure we get the right thing.
|
||||
yarn cache clean
|
||||
# Ensure all dependencies are updated
|
||||
yarn install --ignore-scripts --pure-lockfile
|
||||
yarn install --ignore-scripts --frozen-lockfile
|
||||
|
||||
# ignore leading v on release
|
||||
release="${1#v}"
|
||||
@@ -180,7 +180,7 @@ yarn version --no-git-tag-version --new-version "$release"
|
||||
# they exist). This small bit of gymnastics allows us to use the TypeScript
|
||||
# source directly for development without needing to build before linting or
|
||||
# testing.
|
||||
for i in main typings
|
||||
for i in main typings browser
|
||||
do
|
||||
lib_value=$(jq -r ".matrix_lib_$i" package.json)
|
||||
if [ "$lib_value" != "null" ]; then
|
||||
@@ -225,7 +225,7 @@ if [ $dodist -eq 0 ]; then
|
||||
pushd "$builddir"
|
||||
git clone "$projdir" .
|
||||
git checkout "$rel_branch"
|
||||
yarn install --pure-lockfile
|
||||
yarn install --frozen-lockfile
|
||||
# We haven't tagged yet, so tell the dist script what version
|
||||
# it's building
|
||||
DIST_VERSION="$tag" yarn dist
|
||||
|
||||
@@ -15,4 +15,4 @@ for line in sys.stdin:
|
||||
break
|
||||
found_first_header = True
|
||||
elif not re.match(r"^=+$", line) and len(line) > 0:
|
||||
print line
|
||||
print(line)
|
||||
|
||||
+25
-19
@@ -16,26 +16,33 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
|
||||
/* eslint-disable jest/no-standalone-expect */
|
||||
|
||||
// load olm before the sdk if possible
|
||||
import "./olm-loader";
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
|
||||
import type { IE2EKeyReceiver } from "./test-utils/E2EKeyReceiver";
|
||||
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
|
||||
import { logger } from "../src/logger";
|
||||
import { syncPromise } from "./test-utils/test-utils";
|
||||
import { createClient, IStartClientOpts } 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";
|
||||
import { IKeysUploadResponse, IUploadKeysRequest } from "../src/client";
|
||||
import { ISyncResponder } from "./test-utils/SyncResponder";
|
||||
|
||||
/**
|
||||
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
|
||||
*
|
||||
* @deprecated Avoid using this; it is tied too tightly to matrix-mock-request and is generally inconvenient to use.
|
||||
* Instead, construct a MatrixClient manually, use fetch-mock-jest to intercept the HTTP requests, and
|
||||
* use things like {@link E2EKeyReceiver} and {@link SyncResponder} to manage the requests.
|
||||
*/
|
||||
export class TestClient {
|
||||
export class TestClient implements IE2EKeyReceiver, ISyncResponder {
|
||||
public readonly httpBackend: MockHttpBackend;
|
||||
public readonly client: MatrixClient;
|
||||
public deviceKeys?: IDeviceKeys | null;
|
||||
@@ -205,21 +212,6 @@ export class TestClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -240,8 +232,22 @@ export class TestClient {
|
||||
return this.deviceKeys!.keys[keyId];
|
||||
}
|
||||
|
||||
/** Next time we see a sync request (or immediately, if there is one waiting), send the given response
|
||||
*
|
||||
* Calling this will register a response for `/sync`, and then, in the background, flush a single `/sync` request.
|
||||
* Try calling {@link syncPromise} to wait for the sync to complete.
|
||||
*
|
||||
* @param response - response to /sync request
|
||||
*/
|
||||
public sendOrQueueSyncResponse(syncResponse: object): void {
|
||||
this.httpBackend.when("GET", "/sync").respond(200, syncResponse);
|
||||
this.httpBackend.flush("/sync", 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* flush a single /sync request, and wait for the syncing event
|
||||
*
|
||||
* @deprecated: prefer to use {@link #sendOrQueueSyncResponse} followed by {@link syncPromise}.
|
||||
*/
|
||||
public flushSync(): Promise<void> {
|
||||
logger.log(`${this}: flushSync`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
Copyright 2023 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 fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { AuthDict, createClient, CryptoEvent, MatrixClient } from "../../../src";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
import { encryptAES } from "../../../src/crypto/aes";
|
||||
import { CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
|
||||
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import {
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
} from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
/**
|
||||
* Integration tests for cross-signing functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// newBackendOnly is the opposite to `oldBackendOnly`: it will skip the test if we are running against the legacy
|
||||
// backend. Once we drop support for legacy crypto, it will go away.
|
||||
const newBackendOnly = backend === "rust-sdk" ? test : test.skip;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||
let syncResponder: ISyncResponder;
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
// Encryption key used to encrypt cross signing keys
|
||||
const encryptionKey = new Uint8Array(32);
|
||||
|
||||
/**
|
||||
* Create the {@link CryptoCallbacks}
|
||||
*/
|
||||
function createCryptoCallbacks(): CryptoCallbacks {
|
||||
return {
|
||||
getSecretStorageKey: (keys, name) => {
|
||||
return Promise.resolve<[string, Uint8Array]>(["key_id", encryptionKey]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
});
|
||||
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create cross-signing keys and publish the keys
|
||||
*
|
||||
* @param authDict - The parameters to as the `auth` dict in the key upload request.
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
|
||||
*/
|
||||
async function bootstrapCrossSigning(authDict: AuthDict): Promise<void> {
|
||||
await aliceClient.getCrypto()?.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined),
|
||||
});
|
||||
}
|
||||
|
||||
describe("bootstrapCrossSigning (before initialsync completes)", () => {
|
||||
it("publishes keys if none were yet published", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
// master key is signed by the device
|
||||
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
|
||||
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
|
||||
// ssk and usk are signed by the master key
|
||||
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
|
||||
|
||||
// check the publish call
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// there should be a signature for our device, by our self-signing key.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
|
||||
);
|
||||
});
|
||||
|
||||
newBackendOnly("get cross signing keys from secret storage and import them", async () => {
|
||||
// Return public cross signing keys
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
|
||||
// Encrypt the private keys and return them in the /sync response as if they are in Secret Storage
|
||||
const masterKey = await encryptAES(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
encryptionKey,
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
const selfSigningKey = await encryptAES(
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
encryptionKey,
|
||||
"m.cross_signing.self_signing",
|
||||
);
|
||||
const userSigningKey = await encryptAES(
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
encryptionKey,
|
||||
"m.cross_signing.user_signing",
|
||||
);
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.cross_signing.master",
|
||||
content: {
|
||||
encrypted: {
|
||||
key_id: masterKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.self_signing",
|
||||
content: {
|
||||
encrypted: {
|
||||
key_id: selfSigningKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.cross_signing.user_signing",
|
||||
content: {
|
||||
encrypted: {
|
||||
key_id: userSigningKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "m.secret_storage.key.key_id",
|
||||
content: {
|
||||
key: "key_id",
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
|
||||
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
|
||||
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
||||
);
|
||||
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// Check if the UserTrustStatusChanged event was fired
|
||||
expect(await userTrustStatusChangedPromise).toBe(aliceClient.getUserId());
|
||||
|
||||
// Expect the signature to be uploaded
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// the device should have a signature with the public self cross signing keys.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[ed25519:${SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningStatus()", () => {
|
||||
it("should return correct values without bootstrapping cross-signing", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();
|
||||
|
||||
// Expect the cross signing keys to be unavailable
|
||||
expect(crossSigningStatus).toStrictEqual({
|
||||
publicKeysOnDevice: false,
|
||||
privateKeysInSecretStorage: false,
|
||||
privateKeysCachedLocally: { masterKey: false, userSigningKey: false, selfSigningKey: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return correct values after bootstrapping cross-signing", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();
|
||||
|
||||
// Expect the cross signing keys to be available
|
||||
expect(crossSigningStatus).toStrictEqual({
|
||||
publicKeysOnDevice: true,
|
||||
privateKeysInSecretStorage: false,
|
||||
privateKeysCachedLocally: { masterKey: true, userSigningKey: true, selfSigningKey: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCrossSigningReady()", () => {
|
||||
it("should return false if cross-signing is not bootstrapped", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
|
||||
|
||||
expect(isCrossSigningReady).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return true after bootstrapping cross-signing", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
await bootstrapCrossSigning({ type: "test" });
|
||||
|
||||
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
|
||||
|
||||
expect(isCrossSigningReady).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningKeyId", () => {
|
||||
/**
|
||||
* Intercept /keys/device_signing/upload request and return the cross signing keys
|
||||
* https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv3keysdevice_signingupload
|
||||
*
|
||||
* @returns the cross signing keys
|
||||
*/
|
||||
function awaitCrossSigningKeysUpload() {
|
||||
return new Promise<any>((resolve) => {
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
(url, options) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content);
|
||||
return {};
|
||||
},
|
||||
// Override the routes define in `mockSetupCrossSigningRequests`
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("should return the cross signing key id for each cross signing key", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// Intercept cross signing keys upload
|
||||
const crossSigningKeysPromise = awaitCrossSigningKeysUpload();
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
// Get the cross signing keys
|
||||
const crossSigningKeys = await crossSigningKeysPromise;
|
||||
|
||||
const getPubKey = (crossSigningKey: any) => Object.values(crossSigningKey!.keys)[0];
|
||||
|
||||
const masterKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId();
|
||||
expect(masterKeyId).toBe(getPubKey(crossSigningKeys.master_key));
|
||||
|
||||
const selfSigningKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.SelfSigning);
|
||||
expect(selfSigningKeyId).toBe(getPubKey(crossSigningKeys.self_signing_key));
|
||||
|
||||
const userSigningKeyId = await aliceClient.getCrypto()!.getCrossSigningKeyId(CrossSigningKey.UserSigning);
|
||||
expect(userSigningKeyId).toBe(getPubKey(crossSigningKeys.user_signing_key));
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
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 fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, IEvent, MatrixClient } from "../../../src";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { awaitDecryption, CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
|
||||
|
||||
const ROOM_ID = "!ROOM:ID";
|
||||
|
||||
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
|
||||
const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
|
||||
const SESSION_ID = "o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc";
|
||||
|
||||
const ENCRYPTED_EVENT: Partial<IEvent> = {
|
||||
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 TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
/** an object which intercepts `/sync` requests on the test homeserver */
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
let e2eKeyReceiver: E2EKeyReceiver;
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
mockInitialApiRequests(TEST_HOMESERVER_URL);
|
||||
syncResponder = new SyncResponder(TEST_HOMESERVER_URL);
|
||||
e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL);
|
||||
e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL);
|
||||
e2eKeyResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
|
||||
e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (aliceClient !== undefined) {
|
||||
await aliceClient.stopClient();
|
||||
}
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
...opts,
|
||||
});
|
||||
await initCrypto(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
oldBackendOnly("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [ENCRYPTED_EVENT],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", CURVE25519_KEY_BACKUP_DATA);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
|
||||
|
||||
// start after saving the private key
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
|
||||
// XXX: should we automatically re-check after a device becomes verified?
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.checkKeyBackup();
|
||||
|
||||
// Now, send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await awaitDecryption(event, { waitOnDecryptionFailure: true });
|
||||
expect(event.getContent()).toEqual("testytest");
|
||||
});
|
||||
|
||||
oldBackendOnly("getActiveSessionBackupVersion() should give correct result", async function () {
|
||||
// 404 means that there is no active backup
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/version", 404);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceClient.startClient();
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.checkKeyBackup();
|
||||
|
||||
// At this point there is no backup
|
||||
let backupStatus: string | null;
|
||||
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
||||
expect(backupStatus).toBeNull();
|
||||
|
||||
// Serve a backup with no trusted signature
|
||||
const unsignedBackup = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
delete unsignedBackup.auth_data.signatures;
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/version", unsignedBackup, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
const checked = await aliceClient.checkKeyBackup();
|
||||
expect(checked?.backupInfo?.version).toStrictEqual(unsignedBackup.version);
|
||||
expect(checked?.trustInfo?.usable).toBeFalsy();
|
||||
|
||||
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
||||
expect(backupStatus).toBeNull();
|
||||
|
||||
// Add a valid signature to the backup
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA, {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
|
||||
// check that signalling is working
|
||||
const backupPromise = new Promise<void>((resolve, reject) => {
|
||||
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
|
||||
if (enabled) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const validCheck = await aliceClient.checkKeyBackup();
|
||||
expect(validCheck?.trustInfo?.usable).toStrictEqual(true);
|
||||
|
||||
await backupPromise;
|
||||
|
||||
backupStatus = await aliceCrypto.getActiveSessionBackupVersion();
|
||||
expect(backupStatus).toStrictEqual(testData.SIGNED_BACKUP_DATA.version);
|
||||
});
|
||||
|
||||
describe("isKeyBackupTrusted", () => {
|
||||
it("does not trust a backup signed by an untrusted device", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// download the device list, to match the trusted case
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
|
||||
});
|
||||
|
||||
it("trusts a backup signed by a trusted device", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
// tell Alice to trust the dummy device that signed the backup
|
||||
await aliceClient.startClient();
|
||||
await waitForDeviceList();
|
||||
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
expect(result).toEqual({ trusted: true, matchesDecryptionKey: false });
|
||||
});
|
||||
|
||||
it("recognises a backup which matches the decryption key", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
expect(result).toEqual({ trusted: false, matchesDecryptionKey: true });
|
||||
});
|
||||
|
||||
it("is not fooled by a backup which matches the decryption key but uses a different algorithm", async () => {
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
);
|
||||
|
||||
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
backup.algorithm = "m.megolm_backup.v1.aes-hmac-sha2";
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(backup);
|
||||
expect(result).toEqual({ trusted: false, matchesDecryptionKey: false });
|
||||
});
|
||||
});
|
||||
|
||||
/** make sure that the client knows about the dummy device */
|
||||
async function waitForDeviceList(): Promise<void> {
|
||||
// Completing the initial sync will make the device list download outdated device lists (of which our own
|
||||
// user will be one).
|
||||
syncResponder.sendOrQueueSyncResponse({});
|
||||
// DeviceList has a sleep(5) which we need to make happen
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The client should now know about the dummy device
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
|
||||
expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID);
|
||||
}
|
||||
});
|
||||
@@ -26,16 +26,16 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
// load olm before the sdk if possible
|
||||
import "../olm-loader";
|
||||
import "../../olm-loader";
|
||||
|
||||
import type { Session } from "@matrix-org/olm";
|
||||
import { logger } from "../../src/logger";
|
||||
import * as testUtils from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client";
|
||||
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { IDeviceKeys, IOneTimeKey } from "../../src/crypto/dehydration";
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto";
|
||||
import { logger } from "../../../src/logger";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
|
||||
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
|
||||
let aliTestClient: TestClient;
|
||||
const roomId = "!room:localhost";
|
||||
@@ -472,7 +472,7 @@ describe("MatrixClient crypto", () => {
|
||||
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
|
||||
await aliTestClient.start();
|
||||
await bobTestClient.start();
|
||||
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
|
||||
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
|
||||
await firstSync(aliTestClient);
|
||||
await aliEnablesEncryption();
|
||||
await aliSendsFirstMessage();
|
||||
@@ -483,7 +483,7 @@ describe("MatrixClient crypto", () => {
|
||||
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
|
||||
await aliTestClient.start();
|
||||
await bobTestClient.start();
|
||||
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
|
||||
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
|
||||
await firstSync(aliTestClient);
|
||||
await aliEnablesEncryption();
|
||||
await aliSendsFirstMessage();
|
||||
@@ -545,7 +545,7 @@ describe("MatrixClient crypto", () => {
|
||||
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
|
||||
await aliTestClient.start();
|
||||
await bobTestClient.start();
|
||||
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
|
||||
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
|
||||
await firstSync(aliTestClient);
|
||||
await aliEnablesEncryption();
|
||||
await aliSendsFirstMessage();
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { createClient } from "../../src";
|
||||
import { createClient } from "../../../src";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
File diff suppressed because it is too large
Load Diff
@@ -175,7 +175,7 @@ describe("MatrixClient events", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit User events", function (done) {
|
||||
it("should emit User events", async () => {
|
||||
httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA);
|
||||
let fired = false;
|
||||
@@ -192,10 +192,8 @@ describe("MatrixClient events", function () {
|
||||
});
|
||||
client!.startClient();
|
||||
|
||||
httpBackend!.flushAllExpected().then(function () {
|
||||
expect(fired).toBe(true);
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit Room events", function () {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022 - 2023 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.
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
Filter,
|
||||
IEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
RelationType,
|
||||
Room,
|
||||
} from "../../src/matrix";
|
||||
import { logger } from "../../src/logger";
|
||||
@@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util
|
||||
import { TestClient } from "../TestClient";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
|
||||
const userId = "@alice:localhost";
|
||||
const userName = "Alice";
|
||||
@@ -204,7 +207,7 @@ function startClient(httpBackend: HttpBackend, client: MatrixClient) {
|
||||
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
|
||||
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
|
||||
|
||||
client.startClient();
|
||||
client.startClient({ threadSupport: true });
|
||||
|
||||
// set up a promise which will resolve once the client is initialised
|
||||
const prom = new Promise<void>((resolve) => {
|
||||
@@ -245,7 +248,7 @@ describe("getEventTimeline support", function () {
|
||||
return startClient(httpBackend, client).then(function () {
|
||||
const room = client.getRoom(roomId)!;
|
||||
const timelineSet = room!.getTimelineSets()[0];
|
||||
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,7 +260,18 @@ describe("getEventTimeline support", function () {
|
||||
return startClient(httpBackend, client).then(() => {
|
||||
const room = client.getRoom(roomId)!;
|
||||
const timelineSet = room!.getTimelineSets()[0];
|
||||
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy();
|
||||
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
|
||||
event: {
|
||||
event_id: "event",
|
||||
},
|
||||
events_after: [],
|
||||
events_before: [],
|
||||
state: [],
|
||||
}));
|
||||
return Promise.all([
|
||||
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
|
||||
httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,7 +282,7 @@ describe("getEventTimeline support", function () {
|
||||
|
||||
return startClient(httpBackend, client).then(function () {
|
||||
const timelineSet = new EventTimelineSet(undefined);
|
||||
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -590,17 +604,11 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
@@ -631,12 +639,6 @@ describe("MatrixClient event timelines", function () {
|
||||
const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false);
|
||||
await httpBackend.flushAllExpected();
|
||||
const timelineSet = thread.timelineSet;
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
await flushHttp(emitPromise(thread, ThreadEvent.Update));
|
||||
|
||||
const timeline = await client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
|
||||
|
||||
@@ -647,7 +649,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -680,7 +682,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -709,7 +711,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should return undefined when event is within a thread but timelineSet is not", () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
@@ -787,7 +789,18 @@ describe("MatrixClient event timelines", function () {
|
||||
return startClient(httpBackend, client).then(() => {
|
||||
const room = client.getRoom(roomId)!;
|
||||
const timelineSet = room.getTimelineSets()[0];
|
||||
expect(client.getLatestTimeline(timelineSet)).rejects.toBeFalsy();
|
||||
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
|
||||
event: {
|
||||
event_id: "event",
|
||||
},
|
||||
events_after: [],
|
||||
events_before: [],
|
||||
state: [],
|
||||
}));
|
||||
return Promise.all([
|
||||
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
|
||||
httpBackend.flushAllExpected(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1127,7 +1140,7 @@ describe("MatrixClient event timelines", function () {
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1139,7 +1152,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
@@ -1153,7 +1166,118 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
room.addLiveEvents([THREAD_REPLY3]);
|
||||
await room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ensure thread events don't get reordered with recursive relations", async () => {
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY2 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 2",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
const THREAD_ROOT_REACTION = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: THREAD_ROOT.event_id!,
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
THREAD_ROOT_REACTION.localTimestamp += 2000;
|
||||
|
||||
// Test data for a second reply to the first thread
|
||||
const THREAD_REPLY3 = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply 3",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY3.localTimestamp += 3000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
...THREAD_ROOT,
|
||||
unsigned: {
|
||||
...THREAD_ROOT.unsigned,
|
||||
"m.relations": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"],
|
||||
"io.element.thread": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
|
||||
count: 3,
|
||||
latest_event: THREAD_REPLY3.event,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
|
||||
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
|
||||
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({
|
||||
dir: Direction.Backward,
|
||||
limit: 3,
|
||||
recurse: true,
|
||||
}),
|
||||
)
|
||||
.respond(200, {
|
||||
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
await room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
@@ -1208,7 +1332,7 @@ describe("MatrixClient event timelines", function () {
|
||||
request.respond(200, function () {
|
||||
return {
|
||||
original_event: root,
|
||||
chunk: [replies],
|
||||
chunk: replies,
|
||||
// no next batch as this is the oldest end of the timeline
|
||||
};
|
||||
});
|
||||
@@ -1263,7 +1387,7 @@ describe("MatrixClient event timelines", function () {
|
||||
describe("with server compatibility", function () {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1338,7 +1462,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(room.getPendingEvents()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should handle thread updates by reordering the thread list", async () => {
|
||||
it("should handle new thread replies by reordering the thread list", async () => {
|
||||
// Test data for a second thread
|
||||
const THREAD2_ROOT = utils.mkEvent({
|
||||
room: roomId,
|
||||
@@ -1365,7 +1489,7 @@ describe("MatrixClient event timelines", function () {
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply",
|
||||
"body": "thread2 reply",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
@@ -1385,7 +1509,7 @@ describe("MatrixClient event timelines", function () {
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply",
|
||||
"body": "thread reply2",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
@@ -1395,7 +1519,8 @@ describe("MatrixClient event timelines", function () {
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY2.localTimestamp += 1000;
|
||||
// this has to come after THREAD_REPLY which hasn't been instantiated by us
|
||||
THREAD_REPLY2.localTimestamp += 10000000;
|
||||
|
||||
// Test data for the first thread, with the second reply
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
@@ -1421,7 +1546,7 @@ describe("MatrixClient event timelines", function () {
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1455,10 +1580,8 @@ describe("MatrixClient event timelines", function () {
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.NewReply);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(2);
|
||||
@@ -1468,12 +1591,138 @@ describe("MatrixClient event timelines", function () {
|
||||
THREAD_ROOT.event_id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not reorder the thread list on other thread updates", async () => {
|
||||
// Test data for a second thread
|
||||
const THREAD2_ROOT = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
event: false,
|
||||
});
|
||||
|
||||
const THREAD2_REPLY = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread2 reply",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: false,
|
||||
});
|
||||
|
||||
// @ts-ignore we know this is a defined path for THREAD ROOT
|
||||
THREAD2_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD2_REPLY;
|
||||
|
||||
const THREAD_REPLY_REACTION = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.reaction",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: THREAD_REPLY.event_id,
|
||||
key: "🪿",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY_REACTION.localTimestamp += 1000;
|
||||
|
||||
// Modified thread root event containing latest thread reply in its unsigned
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
...THREAD_ROOT,
|
||||
unsigned: {
|
||||
...THREAD_ROOT.unsigned,
|
||||
"m.relations": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"],
|
||||
"io.element.thread": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
|
||||
count: 2,
|
||||
latest_event: THREAD_REPLY,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Response with test data for the thread list request
|
||||
const threadsResponse = {
|
||||
chunk: [THREAD2_ROOT, THREAD_ROOT],
|
||||
state: [],
|
||||
next_batch: RANDOM_TOKEN as string | null,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
// Set up room threads
|
||||
const timelineSets = await room!.createThreadsTimelineSets();
|
||||
expect(timelineSets).not.toBeNull();
|
||||
respondToThreads(threadsResponse);
|
||||
respondToThreads(threadsResponse);
|
||||
respondToEvent(THREAD_ROOT);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
respondToThread(THREAD_ROOT, [THREAD_REPLY]);
|
||||
respondToThread(THREAD2_ROOT, [THREAD2_REPLY]);
|
||||
await flushHttp(room.fetchRoomThreads());
|
||||
const threadIds = room.getThreads().map((thread) => thread.id);
|
||||
expect(threadIds).toContain(THREAD_ROOT.event_id);
|
||||
expect(threadIds).toContain(THREAD2_ROOT.event_id);
|
||||
const [allThreads] = timelineSets!;
|
||||
const timeline = allThreads.getLiveTimeline()!;
|
||||
// Test threads are in chronological order
|
||||
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD2_ROOT.event_id,
|
||||
]);
|
||||
|
||||
// Test adding a second event to the first thread
|
||||
const thread = room.getThread(THREAD_ROOT.event_id!)!;
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
await room.addLiveEvents([THREAD_REPLY_REACTION]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(1); // reactions don't count towards the length of a thread
|
||||
// Test thread order is unchanged
|
||||
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD2_ROOT.event_id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without server compatibility", function () {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.None);
|
||||
});
|
||||
@@ -1539,7 +1788,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should add lazy loading filter", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
// @ts-ignore
|
||||
@@ -1567,7 +1816,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("should correctly pass pagination token", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
|
||||
@@ -1803,73 +2052,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(thread.initialEventsFetched).toBeTruthy();
|
||||
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/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
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(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return {
|
||||
start: "start_token",
|
||||
events_before: [],
|
||||
event: THREAD_ROOT,
|
||||
events_after: [],
|
||||
end: "end_token",
|
||||
state: [],
|
||||
};
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [],
|
||||
};
|
||||
});
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Forward, from: "end_token" }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
chunk: [THREAD_REPLY],
|
||||
};
|
||||
});
|
||||
|
||||
const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
|
||||
const timeline = await client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!);
|
||||
|
||||
httpBackend.when("GET", "/sync").respond(200, {
|
||||
next_batch: "s_5_5",
|
||||
@@ -1892,7 +2075,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("in stable mode", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
@@ -1902,7 +2085,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("in backwards compatible unstable mode", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental);
|
||||
@@ -1912,7 +2095,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
it("in backwards compatible mode", async () => {
|
||||
// @ts-ignore
|
||||
client.clientOpts.experimentalThreadSupport = true;
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Experimental);
|
||||
Thread.setServerSideListSupport(FeatureSupport.None);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,16 +94,16 @@ describe("MatrixClient opts", function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("should be able to send messages", function (done) {
|
||||
it("should be able to send messages", async () => {
|
||||
const eventId = "$flibble:wibble";
|
||||
httpBackend.when("PUT", "/txn1").respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
done();
|
||||
});
|
||||
httpBackend.flush("/txn1", 1);
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "a body", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
|
||||
it("should be able to sync / get new events", async function () {
|
||||
@@ -149,7 +149,7 @@ describe("MatrixClient opts", function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("shouldn't retry sending events", function (done) {
|
||||
it("shouldn't retry sending events", async () => {
|
||||
httpBackend.when("PUT", "/txn1").respond(
|
||||
500,
|
||||
new MatrixError({
|
||||
@@ -157,19 +157,13 @@ describe("MatrixClient opts", function () {
|
||||
error: "Ruh roh",
|
||||
}),
|
||||
);
|
||||
client.sendTextMessage("!foo:bar", "a body", "txn1").then(
|
||||
function (res) {
|
||||
expect(false).toBe(true);
|
||||
},
|
||||
function (err) {
|
||||
expect(err.errcode).toEqual("M_SOMETHING");
|
||||
done();
|
||||
},
|
||||
);
|
||||
httpBackend.flush("/txn1", 1);
|
||||
|
||||
await expect(
|
||||
Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]),
|
||||
).rejects.toThrow("MatrixError: [500] Unknown message");
|
||||
});
|
||||
|
||||
it("shouldn't queue events", function (done) {
|
||||
it("shouldn't queue events", async () => {
|
||||
httpBackend.when("PUT", "/txn1").respond(200, {
|
||||
event_id: "AAA",
|
||||
});
|
||||
@@ -178,30 +172,38 @@ describe("MatrixClient opts", function () {
|
||||
});
|
||||
let sentA = false;
|
||||
let sentB = false;
|
||||
client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
|
||||
const messageASendPromise = client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
|
||||
sentA = true;
|
||||
// We expect messageB to be sent before messageA to ensure as we're
|
||||
// testing that there is no queueing that blocks each other
|
||||
expect(sentB).toBe(true);
|
||||
});
|
||||
client.sendTextMessage("!foo:bar", "b body", "txn2").then(function (res) {
|
||||
const messageBSendPromise = client.sendTextMessage("!foo:bar", "b body", "txn2").then(function (res) {
|
||||
sentB = true;
|
||||
// We expect messageB to be sent before messageA to ensure as we're
|
||||
// testing that there is no queueing that blocks each other
|
||||
expect(sentA).toBe(false);
|
||||
});
|
||||
httpBackend.flush("/txn2", 1).then(function () {
|
||||
httpBackend.flush("/txn1", 1).then(function () {
|
||||
done();
|
||||
});
|
||||
});
|
||||
// Allow messageB to succeed first
|
||||
await httpBackend.flush("/txn2", 1);
|
||||
// Then allow messageA to succeed
|
||||
await httpBackend.flush("/txn1", 1);
|
||||
|
||||
// Now await the message send promises to
|
||||
await messageBSendPromise;
|
||||
await messageASendPromise;
|
||||
});
|
||||
|
||||
it("should be able to send messages", function (done) {
|
||||
it("should be able to send messages", async () => {
|
||||
httpBackend.when("PUT", "/txn1").respond(200, {
|
||||
event_id: "foo",
|
||||
});
|
||||
client.sendTextMessage("!foo:bar", "a body", "txn1").then(function (res) {
|
||||
expect(res.event_id).toEqual("foo");
|
||||
done();
|
||||
});
|
||||
httpBackend.flush("/txn1", 1);
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "a body", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual("foo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,13 +48,13 @@ describe("MatrixClient retrying", function () {
|
||||
return httpBackend!.stop();
|
||||
});
|
||||
|
||||
xit("should retry according to MatrixScheduler.retryFn", function () {});
|
||||
it.skip("should retry according to MatrixScheduler.retryFn", function () {});
|
||||
|
||||
xit("should queue according to MatrixScheduler.queueFn", function () {});
|
||||
it.skip("should queue according to MatrixScheduler.queueFn", function () {});
|
||||
|
||||
xit("should mark events as EventStatus.NOT_SENT when giving up", function () {});
|
||||
it.skip("should mark events as EventStatus.NOT_SENT when giving up", function () {});
|
||||
|
||||
xit("should mark events as EventStatus.QUEUED when queued", function () {});
|
||||
it.skip("should mark events as EventStatus.QUEUED when queued", function () {});
|
||||
|
||||
it("should mark events as EventStatus.CANCELLED when cancelled", function () {
|
||||
// send a couple of events; the second will be queued
|
||||
@@ -130,7 +130,7 @@ describe("MatrixClient retrying", function () {
|
||||
});
|
||||
|
||||
describe("resending", function () {
|
||||
xit("should be able to resend a NOT_SENT event", function () {});
|
||||
xit("should be able to resend a sent event", function () {});
|
||||
it.skip("should be able to resend a NOT_SENT event", function () {});
|
||||
it.skip("should be able to resend a sent event", function () {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,36 +163,38 @@ describe("MatrixClient room timelines", function () {
|
||||
it(
|
||||
"should be added immediately after calling MatrixClient.sendEvent " +
|
||||
"with EventStatus.SENDING and the right event.sender",
|
||||
function (done) {
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
async () => {
|
||||
const wasMessageAddedPromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, async (state) => {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
|
||||
client!.sendTextMessage(roomId, "I am a fish", "txn1");
|
||||
// check it was added
|
||||
expect(room.timeline.length).toEqual(2);
|
||||
// check status
|
||||
expect(room.timeline[1].status).toEqual(EventStatus.SENDING);
|
||||
// check member
|
||||
const member = room.timeline[1].sender;
|
||||
expect(member?.userId).toEqual(userId);
|
||||
expect(member?.name).toEqual(userName);
|
||||
client!.sendTextMessage(roomId, "I am a fish", "txn1");
|
||||
// check it was added
|
||||
expect(room.timeline.length).toEqual(2);
|
||||
// check status
|
||||
expect(room.timeline[1].status).toEqual(EventStatus.SENDING);
|
||||
// check member
|
||||
const member = room.timeline[1].sender;
|
||||
expect(member?.userId).toEqual(userId);
|
||||
expect(member?.name).toEqual(userName);
|
||||
|
||||
httpBackend!.flush("/sync", 1).then(function () {
|
||||
done();
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await wasMessageAddedPromise;
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should be updated correctly when the send request finishes " +
|
||||
"BEFORE the event comes down the event stream",
|
||||
function (done) {
|
||||
async () => {
|
||||
const eventId = "$foo:bar";
|
||||
httpBackend!.when("PUT", "/txn1").respond(200, {
|
||||
event_id: eventId,
|
||||
@@ -207,28 +209,30 @@ describe("MatrixClient room timelines", function () {
|
||||
ev.unsigned = { transaction_id: "txn1" };
|
||||
setNextSyncData([ev]);
|
||||
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
client!.sendTextMessage(roomId, "I am a fish", "txn1").then(function () {
|
||||
expect(room.timeline[1].getId()).toEqual(eventId);
|
||||
httpBackend!.flush("/sync", 1).then(function () {
|
||||
const wasMessageAddedPromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
client!.sendTextMessage(roomId, "I am a fish", "txn1").then(async () => {
|
||||
expect(room.timeline[1].getId()).toEqual(eventId);
|
||||
done();
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
expect(room.timeline[1].getId()).toEqual(eventId);
|
||||
resolve(null);
|
||||
});
|
||||
httpBackend!.flush("/txn1", 1);
|
||||
});
|
||||
httpBackend!.flush("/txn1", 1);
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await wasMessageAddedPromise;
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should be updated correctly when the send request finishes " +
|
||||
"AFTER the event comes down the event stream",
|
||||
function (done) {
|
||||
async () => {
|
||||
const eventId = "$foo:bar";
|
||||
httpBackend!.when("PUT", "/txn1").respond(200, {
|
||||
event_id: eventId,
|
||||
@@ -243,23 +247,24 @@ describe("MatrixClient room timelines", function () {
|
||||
ev.unsigned = { transaction_id: "txn1" };
|
||||
setNextSyncData([ev]);
|
||||
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
const promise = client!.sendTextMessage(roomId, "I am a fish", "txn1");
|
||||
httpBackend!.flush("/sync", 1).then(function () {
|
||||
const wasMessageAddedPromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, async (state) => {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
const messageSendPromise = client!.sendTextMessage(roomId, "I am a fish", "txn1");
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
expect(room.timeline.length).toEqual(2);
|
||||
httpBackend!.flush("/txn1", 1);
|
||||
promise.then(function () {
|
||||
expect(room.timeline.length).toEqual(2);
|
||||
expect(room.timeline[1].getId()).toEqual(eventId);
|
||||
done();
|
||||
});
|
||||
await messageSendPromise;
|
||||
expect(room.timeline.length).toEqual(2);
|
||||
expect(room.timeline[1].getId()).toEqual(eventId);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await wasMessageAddedPromise;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -279,30 +284,29 @@ describe("MatrixClient room timelines", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("should set Room.oldState.paginationToken to null at the start" + " of the timeline.", function (done) {
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
it("should set Room.oldState.paginationToken to null at the start of the timeline.", async () => {
|
||||
const didPaginatePromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, async (state) => {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
|
||||
client!.scrollback(room).then(function () {
|
||||
await Promise.all([client!.scrollback(room), httpBackend!.flush("/messages", 1)]);
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
expect(room.oldState.paginationToken).toBe(null);
|
||||
|
||||
// still have a sync to flush
|
||||
httpBackend!.flush("/sync", 1).then(() => {
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
httpBackend!.flush("/messages", 1);
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await didPaginatePromise;
|
||||
});
|
||||
|
||||
it("should set the right event.sender values", function (done) {
|
||||
it("should set the right event.sender values", async () => {
|
||||
// We're aiming for an eventual timeline of:
|
||||
//
|
||||
// 'Old Alice' joined the room
|
||||
@@ -353,15 +357,17 @@ describe("MatrixClient room timelines", function () {
|
||||
joinMshipEvent,
|
||||
];
|
||||
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
// sync response
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
const didPaginatePromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, async (state) => {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
// sync response
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
|
||||
await Promise.all([client!.scrollback(room), httpBackend!.flush("/messages", 1)]);
|
||||
|
||||
client!.scrollback(room).then(function () {
|
||||
expect(room.timeline.length).toEqual(5);
|
||||
const joinMsg = room.timeline[0];
|
||||
expect(joinMsg.sender?.name).toEqual("Old Alice");
|
||||
@@ -371,17 +377,15 @@ describe("MatrixClient room timelines", function () {
|
||||
expect(newMsg.sender?.name).toEqual(userName);
|
||||
|
||||
// still have a sync to flush
|
||||
httpBackend!.flush("/sync", 1).then(() => {
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
httpBackend!.flush("/messages", 1);
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await didPaginatePromise;
|
||||
});
|
||||
|
||||
it("should add it them to the right place in the timeline", function (done) {
|
||||
it("should add it them to the right place in the timeline", async () => {
|
||||
// set the list of events to return on scrollback
|
||||
sbEvents = [
|
||||
utils.mkMessage({
|
||||
@@ -396,30 +400,30 @@ describe("MatrixClient room timelines", function () {
|
||||
}),
|
||||
];
|
||||
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
const didPaginatePromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, async (state) => {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.timeline.length).toEqual(1);
|
||||
|
||||
await Promise.all([client!.scrollback(room), httpBackend!.flush("/messages", 1)]);
|
||||
|
||||
client!.scrollback(room).then(function () {
|
||||
expect(room.timeline.length).toEqual(3);
|
||||
expect(room.timeline[0].event).toEqual(sbEvents[1]);
|
||||
expect(room.timeline[1].event).toEqual(sbEvents[0]);
|
||||
|
||||
// still have a sync to flush
|
||||
httpBackend!.flush("/sync", 1).then(() => {
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
httpBackend!.flush("/messages", 1);
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await didPaginatePromise;
|
||||
});
|
||||
|
||||
it("should use 'end' as the next pagination token", function (done) {
|
||||
it("should use 'end' as the next pagination token", async () => {
|
||||
// set the list of events to return on scrollback
|
||||
sbEvents = [
|
||||
utils.mkMessage({
|
||||
@@ -429,25 +433,24 @@ describe("MatrixClient room timelines", function () {
|
||||
}),
|
||||
];
|
||||
|
||||
client!.on(ClientEvent.Sync, function (state) {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.oldState.paginationToken).toBeTruthy();
|
||||
const didPaginatePromise = new Promise((resolve) => {
|
||||
client!.on(ClientEvent.Sync, async (state) => {
|
||||
if (state !== "PREPARED") {
|
||||
return;
|
||||
}
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room.oldState.paginationToken).toBeTruthy();
|
||||
|
||||
client!.scrollback(room, 1).then(function () {
|
||||
await Promise.all([client!.scrollback(room, 1), httpBackend!.flush("/messages", 1)]);
|
||||
expect(room.oldState.paginationToken).toEqual(sbEndTok);
|
||||
});
|
||||
|
||||
httpBackend!.flush("/messages", 1).then(function () {
|
||||
// still have a sync to flush
|
||||
httpBackend!.flush("/sync", 1).then(() => {
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
httpBackend!.flush("/sync", 1);
|
||||
await httpBackend!.flush("/sync", 1);
|
||||
await didPaginatePromise;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,11 +36,16 @@ import {
|
||||
NotificationCountType,
|
||||
IEphemeral,
|
||||
Room,
|
||||
IndexedDBStore,
|
||||
RelationType,
|
||||
EventType,
|
||||
} from "../../src";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
|
||||
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
|
||||
describe("MatrixClient syncing", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
@@ -81,17 +86,15 @@ describe("MatrixClient syncing", () => {
|
||||
presence: {},
|
||||
};
|
||||
|
||||
it("should /sync after /pushrules and /filter.", (done) => {
|
||||
it("should /sync after /pushrules and /filter.", async () => {
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
client!.startClient();
|
||||
|
||||
httpBackend!.flushAllExpected().then(() => {
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
});
|
||||
|
||||
it("should pass the 'next_batch' token from /sync to the since= param of the next /sync", (done) => {
|
||||
it("should pass the 'next_batch' token from /sync to the since= param of the next /sync", async () => {
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
httpBackend!
|
||||
.when("GET", "/sync")
|
||||
@@ -102,9 +105,7 @@ describe("MatrixClient syncing", () => {
|
||||
|
||||
client!.startClient();
|
||||
|
||||
httpBackend!.flushAllExpected().then(() => {
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
});
|
||||
|
||||
it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => {
|
||||
@@ -724,7 +725,7 @@ describe("MatrixClient syncing", () => {
|
||||
// events that arrive in the incremental sync as if they preceeded the
|
||||
// timeline events, however this breaks peeking, so it's disabled
|
||||
// (see sync.js)
|
||||
xit("should correctly interpret state in incremental sync.", () => {
|
||||
it.skip("should correctly interpret state in incremental sync.", () => {
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
httpBackend!.when("GET", "/sync").respond(200, nextSyncData);
|
||||
|
||||
@@ -741,9 +742,9 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
xit("should update power levels for users in a room", () => {});
|
||||
it.skip("should update power levels for users in a room", () => {});
|
||||
|
||||
xit("should update the room topic", () => {});
|
||||
it.skip("should update the room topic", () => {});
|
||||
|
||||
describe("onMarkerStateEvent", () => {
|
||||
const normalMessageEvent = utils.mkMessage({
|
||||
@@ -840,6 +841,7 @@ describe("MatrixClient syncing", () => {
|
||||
roomVersion: "org.matrix.msc2716v3",
|
||||
},
|
||||
].forEach((testMeta) => {
|
||||
// eslint-disable-next-line jest/valid-title
|
||||
describe(testMeta.label, () => {
|
||||
const roomCreateEvent = utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
@@ -1589,30 +1591,89 @@ describe("MatrixClient syncing", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply encrypted notification logic for events within the same sync blob", async () => {
|
||||
const roomId = "!room123:server";
|
||||
const syncData = {
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: {
|
||||
ephemeral: {
|
||||
events: [],
|
||||
},
|
||||
timeline: {
|
||||
events: [
|
||||
utils.mkEvent({
|
||||
room: roomId,
|
||||
event: true,
|
||||
skey: "",
|
||||
type: EventType.RoomEncryption,
|
||||
content: {},
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: otherUserId,
|
||||
msg: "hello",
|
||||
}),
|
||||
],
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: "join",
|
||||
user: otherUserId,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
room: roomId,
|
||||
mship: "join",
|
||||
user: selfUserId,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
creator: selfUserId,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ISyncResponse;
|
||||
|
||||
httpBackend!.when("GET", "/sync").respond(200, syncData);
|
||||
client!.startClient();
|
||||
|
||||
await Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]);
|
||||
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room.getRoomUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("of a room", () => {
|
||||
xit(
|
||||
it.skip(
|
||||
"should sync when a join event (which changes state) for the user" +
|
||||
" arrives down the event stream (e.g. join from another device)",
|
||||
() => {},
|
||||
);
|
||||
|
||||
xit("should sync when the user explicitly calls joinRoom", () => {});
|
||||
it.skip("should sync when the user explicitly calls joinRoom", () => {});
|
||||
});
|
||||
|
||||
describe("syncLeftRooms", () => {
|
||||
beforeEach((done) => {
|
||||
beforeEach(async () => {
|
||||
client!.startClient();
|
||||
|
||||
httpBackend!.flushAllExpected().then(() => {
|
||||
// the /sync call from syncLeftRooms ends up in the request
|
||||
// queue behind the call from the running client; add a response
|
||||
// to flush the client's one out.
|
||||
httpBackend!.when("GET", "/sync").respond(200, {});
|
||||
|
||||
done();
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
// the /sync call from syncLeftRooms ends up in the request
|
||||
// queue behind the call from the running client; add a response
|
||||
// to flush the client's one out.
|
||||
await httpBackend!.when("GET", "/sync").respond(200, {});
|
||||
});
|
||||
|
||||
it("should create and use an appropriate filter", () => {
|
||||
@@ -1873,4 +1934,124 @@ describe("MatrixClient syncing (IndexedDB version)", () => {
|
||||
idbClient.stopClient();
|
||||
idbHttpBackend.stop();
|
||||
});
|
||||
|
||||
it("should query server for which thread a 2nd order relation belongs to and stash in sync accumulator", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
async function startClient(client: MatrixClient): Promise<void> {
|
||||
await Promise.all([
|
||||
idbClient.startClient({
|
||||
// Without this all events just go into the main timeline
|
||||
threadSupport: true,
|
||||
}),
|
||||
idbHttpBackend.flushAllExpected(),
|
||||
emitPromise(idbClient, ClientEvent.Room),
|
||||
]);
|
||||
}
|
||||
|
||||
function assertEventsExpected(client: MatrixClient): void {
|
||||
const room = client.getRoom(roomId);
|
||||
const mainTimelineEvents = room!.getLiveTimeline().getEvents();
|
||||
expect(mainTimelineEvents).toHaveLength(1);
|
||||
expect(mainTimelineEvents[0].getContent().body).toEqual("Test");
|
||||
|
||||
const thread = room!.getThread("$someThreadId")!;
|
||||
expect(thread.replayEvents).toHaveLength(1);
|
||||
expect(thread.replayEvents![0].getRelation()!.key).toEqual("🪿");
|
||||
}
|
||||
|
||||
let idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
|
||||
store: new IndexedDBStore({
|
||||
indexedDB: global.indexedDB,
|
||||
dbName: "test",
|
||||
}),
|
||||
});
|
||||
let idbHttpBackend = idbTestClient.httpBackend;
|
||||
let idbClient = idbTestClient.client;
|
||||
await idbClient.store.startup();
|
||||
|
||||
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
|
||||
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
|
||||
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
|
||||
const syncRoomSection = {
|
||||
join: {
|
||||
[roomId]: {
|
||||
timeline: {
|
||||
prev_batch: "foo",
|
||||
events: [
|
||||
mkMessage({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
msg: "Test",
|
||||
}),
|
||||
mkEvent({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$someUnknownEvent",
|
||||
key: "🪿",
|
||||
},
|
||||
},
|
||||
type: "m.reaction",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
idbHttpBackend.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: syncRoomSection,
|
||||
});
|
||||
idbHttpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/event/%24someUnknownEvent`).respond(
|
||||
200,
|
||||
mkEvent({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
"body": "Thread response",
|
||||
"m.relates_to": {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
event_id: "$someThreadId",
|
||||
},
|
||||
},
|
||||
type: "m.room.message",
|
||||
}),
|
||||
);
|
||||
|
||||
await startClient(idbClient);
|
||||
assertEventsExpected(idbClient);
|
||||
|
||||
idbHttpBackend.verifyNoOutstandingExpectation();
|
||||
// Force sync accumulator to persist, reset client, assert it doesn't re-fetch event on next start-up
|
||||
await idbClient.store.save(true);
|
||||
await idbClient.stopClient();
|
||||
await idbClient.store.destroy();
|
||||
await idbHttpBackend.stop();
|
||||
|
||||
idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
|
||||
store: new IndexedDBStore({
|
||||
indexedDB: global.indexedDB,
|
||||
dbName: "test",
|
||||
}),
|
||||
});
|
||||
idbHttpBackend = idbTestClient.httpBackend;
|
||||
idbClient = idbTestClient.client;
|
||||
await idbClient.store.startup();
|
||||
|
||||
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
|
||||
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
|
||||
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
idbHttpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
await startClient(idbClient);
|
||||
assertEventsExpected(idbClient);
|
||||
|
||||
idbHttpBackend.verifyNoOutstandingExpectation();
|
||||
await idbClient.stopClient();
|
||||
await idbHttpBackend.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
Copyright 2023 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 "fake-indexeddb/auto";
|
||||
|
||||
import HttpBackend from "matrix-mock-request";
|
||||
|
||||
import {
|
||||
Category,
|
||||
ClientEvent,
|
||||
EventType,
|
||||
ISyncResponse,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
} from "../../src";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { mkThread } from "../test-utils/thread";
|
||||
import { SyncState } from "../../src/sync";
|
||||
|
||||
describe("MatrixClient syncing", () => {
|
||||
const userA = "@alice:localhost";
|
||||
const userB = "@bob:localhost";
|
||||
|
||||
const selfUserId = userA;
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
|
||||
let client: MatrixClient | undefined;
|
||||
let httpBackend: HttpBackend | undefined;
|
||||
|
||||
const setupTestClient = (): [MatrixClient, HttpBackend] => {
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
const httpBackend = testClient.httpBackend;
|
||||
const client = testClient.client;
|
||||
httpBackend!.when("GET", "/versions").respond(200, {});
|
||||
httpBackend!.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
return [client, httpBackend];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
[client, httpBackend] = setupTestClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpBackend!.verifyNoOutstandingExpectation();
|
||||
client!.stopClient();
|
||||
return httpBackend!.stop();
|
||||
});
|
||||
|
||||
it("reactions in thread set the correct timeline to unread", async () => {
|
||||
const roomId = "!room:localhost";
|
||||
|
||||
// start the client, and wait for it to initialise
|
||||
httpBackend!.when("GET", "/sync").respond(200, {
|
||||
next_batch: "s_5_3",
|
||||
rooms: {
|
||||
[Category.Join]: {},
|
||||
[Category.Leave]: {},
|
||||
[Category.Invite]: {},
|
||||
},
|
||||
});
|
||||
client!.startClient({ threadSupport: true });
|
||||
await Promise.all([
|
||||
httpBackend?.flushAllExpected(),
|
||||
new Promise<void>((resolve) => {
|
||||
client!.on(ClientEvent.Sync, (state) => state === SyncState.Syncing && resolve());
|
||||
}),
|
||||
]);
|
||||
|
||||
const room = new Room(roomId, client!, selfUserId);
|
||||
jest.spyOn(client!, "getRoom").mockImplementation((id) => (id === roomId ? room : null));
|
||||
|
||||
const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] });
|
||||
const threadReply = thread.events.at(-1)!;
|
||||
await room.addLiveEvents([thread.rootEvent]);
|
||||
|
||||
// Initialize read receipt datastructure before testing the reaction
|
||||
room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false);
|
||||
thread.thread.addReceiptToStructure(
|
||||
threadReply.getId()!,
|
||||
ReceiptType.Read,
|
||||
selfUserId,
|
||||
{ thread_id: thread.thread.id, ts: 1 },
|
||||
false,
|
||||
);
|
||||
expect(room.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(thread.rootEvent.getId());
|
||||
expect(thread.thread.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(threadReply.getId());
|
||||
|
||||
const reactionEventId = `$9-${Math.random()}-${Math.random()}`;
|
||||
let lastEvent: MatrixEvent | null = null;
|
||||
jest.spyOn(client! as any, "sendEventHttpRequest").mockImplementation((event) => {
|
||||
lastEvent = event as MatrixEvent;
|
||||
return { event_id: reactionEventId };
|
||||
});
|
||||
|
||||
await client!.sendEvent(roomId, EventType.Reaction, {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: threadReply.getId(),
|
||||
key: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastEvent!.getId()).toEqual(reactionEventId);
|
||||
room.handleRemoteEcho(new MatrixEvent(lastEvent!.event), lastEvent!);
|
||||
|
||||
// Our ideal state after this is the following:
|
||||
//
|
||||
// Room: [synthetic: threadroot, actual: threadroot]
|
||||
// Thread: [synthetic: threadreaction, actual: threadreply]
|
||||
//
|
||||
// The reaction and reply are both in the thread, and their receipts should be isolated to the thread.
|
||||
// The reaction has not been acknowledged in a dedicated read receipt message, so only the synthetic receipt
|
||||
// should be updated.
|
||||
|
||||
// Ensure the synthetic receipt for the room has not been updated
|
||||
expect(room.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(thread.rootEvent.getId());
|
||||
expect(room.getEventReadUpTo(selfUserId, false)).toEqual(thread.rootEvent.getId());
|
||||
// Ensure the actual receipt for the room has not been updated
|
||||
expect(room.getReadReceiptForUserId(selfUserId, true)?.eventId).toEqual(thread.rootEvent.getId());
|
||||
expect(room.getEventReadUpTo(selfUserId, true)).toEqual(thread.rootEvent.getId());
|
||||
// Ensure the synthetic receipt for the thread has been updated
|
||||
expect(thread.thread.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(reactionEventId);
|
||||
expect(thread.thread.getEventReadUpTo(selfUserId, false)).toEqual(reactionEventId);
|
||||
// Ensure the actual receipt for the thread has not been updated
|
||||
expect(thread.thread.getReadReceiptForUserId(selfUserId, true)?.eventId).toEqual(threadReply.getId());
|
||||
expect(thread.thread.getEventReadUpTo(selfUserId, true)).toEqual(threadReply.getId());
|
||||
});
|
||||
|
||||
describe("Stuck unread notifications integration tests", () => {
|
||||
const ROOM_ID = "!room:localhost";
|
||||
|
||||
const syncData = getSampleStuckNotificationSyncResponse(ROOM_ID);
|
||||
|
||||
it("resets notifications if the last event originates from the logged in user", async () => {
|
||||
httpBackend!
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
expect(req.queryParams!.filter).toEqual("a filter id");
|
||||
})
|
||||
.respond(200, syncData);
|
||||
|
||||
client!.store.getSavedSyncToken = jest.fn().mockResolvedValue("this-is-a-token");
|
||||
client!.startClient({ initialSyncLimit: 1 });
|
||||
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
const room = client?.getRoom(ROOM_ID);
|
||||
|
||||
expect(room).toBeInstanceOf(Room);
|
||||
expect(room?.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function getSampleStuckNotificationSyncResponse(roomId: string): Partial<ISyncResponse> {
|
||||
return {
|
||||
next_batch: "batch_token",
|
||||
rooms: {
|
||||
[Category.Join]: {
|
||||
[roomId]: {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
creator: userB,
|
||||
room_version: "9",
|
||||
},
|
||||
origin_server_ts: 1,
|
||||
sender: userB,
|
||||
state_key: "",
|
||||
type: "m.room.create",
|
||||
event_id: "$event1",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
avatar_url: "",
|
||||
displayname: userB,
|
||||
membership: "join",
|
||||
},
|
||||
origin_server_ts: 2,
|
||||
sender: userB,
|
||||
state_key: userB,
|
||||
type: "m.room.member",
|
||||
event_id: "$event2",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
ban: 50,
|
||||
events: {
|
||||
"m.room.avatar": 50,
|
||||
"m.room.canonical_alias": 50,
|
||||
"m.room.encryption": 100,
|
||||
"m.room.history_visibility": 100,
|
||||
"m.room.name": 50,
|
||||
"m.room.power_levels": 100,
|
||||
"m.room.server_acl": 100,
|
||||
"m.room.tombstone": 100,
|
||||
},
|
||||
events_default: 0,
|
||||
historical: 100,
|
||||
invite: 0,
|
||||
kick: 50,
|
||||
redact: 50,
|
||||
state_default: 50,
|
||||
users: {
|
||||
[userA]: 100,
|
||||
[userB]: 100,
|
||||
},
|
||||
users_default: 0,
|
||||
},
|
||||
origin_server_ts: 3,
|
||||
sender: userB,
|
||||
state_key: "",
|
||||
type: "m.room.power_levels",
|
||||
event_id: "$event3",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
join_rule: "invite",
|
||||
},
|
||||
origin_server_ts: 4,
|
||||
sender: userB,
|
||||
state_key: "",
|
||||
type: "m.room.join_rules",
|
||||
event_id: "$event4",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
history_visibility: "shared",
|
||||
},
|
||||
origin_server_ts: 5,
|
||||
sender: userB,
|
||||
state_key: "",
|
||||
type: "m.room.history_visibility",
|
||||
event_id: "$event5",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
guest_access: "can_join",
|
||||
},
|
||||
origin_server_ts: 6,
|
||||
sender: userB,
|
||||
state_key: "",
|
||||
type: "m.room.guest_access",
|
||||
unsigned: {
|
||||
age: 1651569,
|
||||
},
|
||||
event_id: "$event6",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
origin_server_ts: 7,
|
||||
sender: userB,
|
||||
state_key: "",
|
||||
type: "m.room.encryption",
|
||||
event_id: "$event7",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
avatar_url: "",
|
||||
displayname: userA,
|
||||
is_direct: true,
|
||||
membership: "invite",
|
||||
},
|
||||
origin_server_ts: 8,
|
||||
sender: userB,
|
||||
state_key: userA,
|
||||
type: "m.room.member",
|
||||
event_id: "$event8",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
origin_server_ts: 9,
|
||||
sender: userB,
|
||||
type: "m.room.message",
|
||||
event_id: "$event9",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
avatar_url: "",
|
||||
displayname: userA,
|
||||
membership: "join",
|
||||
},
|
||||
origin_server_ts: 10,
|
||||
sender: userA,
|
||||
state_key: userA,
|
||||
type: "m.room.member",
|
||||
event_id: "$event10",
|
||||
},
|
||||
{
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "world",
|
||||
},
|
||||
origin_server_ts: 11,
|
||||
sender: userA,
|
||||
type: "m.room.message",
|
||||
event_id: "$event11",
|
||||
},
|
||||
],
|
||||
prev_batch: "123",
|
||||
limited: false,
|
||||
},
|
||||
state: {
|
||||
events: [],
|
||||
},
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
type: "m.fully_read",
|
||||
content: {
|
||||
event_id: "$dER5V1RCMxzAhHXQJoMjqyuoxpPtK2X6hCb9T8Jg2wU",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ephemeral: {
|
||||
events: [
|
||||
{
|
||||
type: "m.receipt",
|
||||
content: {
|
||||
$event9: {
|
||||
"m.read": {
|
||||
[userA]: {
|
||||
ts: 100,
|
||||
},
|
||||
},
|
||||
"m.read.private": {
|
||||
[userA]: {
|
||||
ts: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
dER5V1RCMxzAhHXQJoMjqyuoxpPtK2X6hCb9T8Jg2wU: {
|
||||
"m.read": {
|
||||
[userB]: {
|
||||
ts: 666,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
unread_notifications: {
|
||||
notification_count: 1,
|
||||
highlight_count: 0,
|
||||
},
|
||||
summary: {
|
||||
"m.joined_member_count": 2,
|
||||
"m.invited_member_count": 0,
|
||||
"m.heroes": [userB],
|
||||
},
|
||||
},
|
||||
},
|
||||
[Category.Leave]: {},
|
||||
[Category.Invite]: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
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<IEvent> = {
|
||||
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<Olm.Session> {
|
||||
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: Olm.Account;
|
||||
let aliceTestClient: TestClient;
|
||||
|
||||
const setupTestClient = (): [Account, TestClient] => {
|
||||
const aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs");
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount!.create();
|
||||
|
||||
return [testOlmAccount, aliceTestClient];
|
||||
};
|
||||
|
||||
beforeAll(function () {
|
||||
return Olm.init();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
[testOlmAccount, aliceTestClient] = setupTestClient();
|
||||
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: {
|
||||
[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<MatrixEvent> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { fail } from "assert";
|
||||
|
||||
import { SlidingSync, SlidingSyncEvent, MSC3575RoomData, SlidingSyncState, Extension } from "../../src/sliding-sync";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { IRoomEvent, IStateEvent } from "../../src/sync-accumulator";
|
||||
import { IRoomEvent, IStateEvent } from "../../src";
|
||||
import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
@@ -39,9 +39,10 @@ import {
|
||||
} from "../../src";
|
||||
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
|
||||
import { SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { IStoredClientOpts } from "../../src/client";
|
||||
import { IStoredClientOpts } from "../../src";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { defer } from "../../src/utils";
|
||||
|
||||
describe("SlidingSyncSdk", () => {
|
||||
let client: MatrixClient | undefined;
|
||||
@@ -153,11 +154,11 @@ describe("SlidingSyncSdk", () => {
|
||||
const hasSynced = sdk!.sync();
|
||||
await httpBackend!.flushAllExpected();
|
||||
await hasSynced;
|
||||
expect(mockSlidingSync!.start).toBeCalled();
|
||||
expect(mockSlidingSync!.start).toHaveBeenCalled();
|
||||
});
|
||||
it("can stop()", async () => {
|
||||
sdk!.stop();
|
||||
expect(mockSlidingSync!.stop).toBeCalled();
|
||||
expect(mockSlidingSync!.stop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,67 +302,57 @@ describe("SlidingSyncSdk", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("can be created with required_state and timeline", () => {
|
||||
it("can be created with required_state and timeline", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomA].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomA].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
||||
});
|
||||
|
||||
it("can be created with timeline only", () => {
|
||||
it("can be created with timeline only", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomB].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomB].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
||||
});
|
||||
|
||||
it("can be created with a highlight_count", () => {
|
||||
it("can be created with a highlight_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomC);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
||||
data[roomC].highlight_count,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be created with a notification_count", () => {
|
||||
it("can be created with a notification_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomD);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
||||
data[roomD].notification_count,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be created with an invited/joined_count", () => {
|
||||
it("can be created with an invited/joined_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomG);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
||||
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
||||
expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
||||
});
|
||||
|
||||
it("can be created with live events", () => {
|
||||
let seenLiveEvent = false;
|
||||
it("can be created with live events", async () => {
|
||||
const seenLiveEventDeferred = defer<boolean>();
|
||||
const listener = (
|
||||
ev: MatrixEvent,
|
||||
room?: Room,
|
||||
@@ -371,43 +362,37 @@ describe("SlidingSyncSdk", () => {
|
||||
) => {
|
||||
if (timelineData?.liveEvent) {
|
||||
assertTimelineEvents([ev], data[roomH].timeline.slice(-1));
|
||||
seenLiveEvent = true;
|
||||
seenLiveEventDeferred.resolve(true);
|
||||
}
|
||||
};
|
||||
client!.on(RoomEvent.Timeline, listener);
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
client!.off(RoomEvent.Timeline, listener);
|
||||
const gotRoom = client!.getRoom(roomH);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomH].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomH].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
// check the entire timeline is correct
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline);
|
||||
expect(seenLiveEvent).toBe(true);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline);
|
||||
await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("can be created with invite_state", () => {
|
||||
it("can be created with invite_state", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomE);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getMyMembership()).toEqual("invite");
|
||||
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getMyMembership()).toEqual("invite");
|
||||
expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
||||
});
|
||||
|
||||
it("uses the 'name' field to caluclate the room name", () => {
|
||||
it("uses the 'name' field to caluclate the room name", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomF);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomF].name);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomF].name);
|
||||
});
|
||||
|
||||
describe("updating", () => {
|
||||
@@ -419,33 +404,33 @@ describe("SlidingSyncSdk", () => {
|
||||
name: data[roomA].name,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
const newTimeline = data[roomA].timeline;
|
||||
newTimeline.push(newEvent);
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
||||
});
|
||||
|
||||
it("can update with a new required_state event", async () => {
|
||||
let gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
|
||||
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, {
|
||||
required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")],
|
||||
timeline: [],
|
||||
name: data[roomB].name,
|
||||
});
|
||||
gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
|
||||
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted);
|
||||
});
|
||||
|
||||
it("can update with a new highlight_count", async () => {
|
||||
@@ -456,11 +441,11 @@ describe("SlidingSyncSdk", () => {
|
||||
highlight_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomC);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
||||
});
|
||||
|
||||
it("can update with a new notification_count", async () => {
|
||||
@@ -471,11 +456,11 @@ describe("SlidingSyncSdk", () => {
|
||||
notification_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomD);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
||||
});
|
||||
|
||||
it("can update with a new joined_count", () => {
|
||||
@@ -486,11 +471,11 @@ describe("SlidingSyncSdk", () => {
|
||||
joined_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomG);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
|
||||
expect(gotRoom!.getJoinedMemberCount()).toEqual(1);
|
||||
});
|
||||
|
||||
// Regression test for a bug which caused the timeline entries to be out-of-order
|
||||
@@ -512,7 +497,7 @@ describe("SlidingSyncSdk", () => {
|
||||
initial: true, // e.g requested via room subscription
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +515,7 @@ describe("SlidingSyncSdk", () => {
|
||||
);
|
||||
|
||||
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -584,7 +569,7 @@ describe("SlidingSyncSdk", () => {
|
||||
});
|
||||
|
||||
it("emits SyncState.Error immediately when receiving M_UNKNOWN_TOKEN and stops syncing", async () => {
|
||||
expect(mockSlidingSync!.stop).not.toBeCalled();
|
||||
expect(mockSlidingSync!.stop).not.toHaveBeenCalled();
|
||||
mockSlidingSync!.emit(
|
||||
SlidingSyncEvent.Lifecycle,
|
||||
SlidingSyncState.RequestFinished,
|
||||
@@ -595,7 +580,7 @@ describe("SlidingSyncSdk", () => {
|
||||
}),
|
||||
);
|
||||
expect(sdk!.getSyncState()).toEqual(SyncState.Error);
|
||||
expect(mockSlidingSync!.stop).toBeCalled();
|
||||
expect(mockSlidingSync!.stop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -626,9 +611,9 @@ describe("SlidingSyncSdk", () => {
|
||||
await httpBackend!.flush("/profile", 1, 1000);
|
||||
await emitPromise(client!, RoomMemberEvent.Name);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const inviteeMember = room.getMember(invitee)!;
|
||||
expect(inviteeMember).toBeDefined();
|
||||
expect(inviteeMember).toBeTruthy();
|
||||
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
|
||||
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
|
||||
});
|
||||
@@ -662,41 +647,30 @@ describe("SlidingSyncSdk", () => {
|
||||
});
|
||||
|
||||
it("can update device lists", () => {
|
||||
client!.crypto!.processDeviceLists = jest.fn();
|
||||
ext.onResponse({
|
||||
device_lists: {
|
||||
changed: ["@alice:localhost"],
|
||||
left: ["@bob:localhost"],
|
||||
},
|
||||
});
|
||||
// TODO: more assertions?
|
||||
expect(client!.crypto!.processDeviceLists).toHaveBeenCalledWith({
|
||||
changed: ["@alice:localhost"],
|
||||
left: ["@bob:localhost"],
|
||||
});
|
||||
});
|
||||
|
||||
it("can update OTK counts", () => {
|
||||
client!.crypto!.updateOneTimeKeyCount = jest.fn();
|
||||
it("can update OTK counts and unused fallback keys", () => {
|
||||
client!.crypto!.processKeyCounts = jest.fn();
|
||||
ext.onResponse({
|
||||
device_one_time_keys_count: {
|
||||
signed_curve25519: 42,
|
||||
},
|
||||
});
|
||||
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(42);
|
||||
ext.onResponse({
|
||||
device_one_time_keys_count: {
|
||||
not_signed_curve25519: 42,
|
||||
// missing field -> default to 0
|
||||
},
|
||||
});
|
||||
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("can update fallback keys", () => {
|
||||
ext.onResponse({
|
||||
device_unused_fallback_key_types: ["signed_curve25519"],
|
||||
});
|
||||
expect(client!.crypto!.getNeedsNewFallback()).toEqual(false);
|
||||
ext.onResponse({
|
||||
device_unused_fallback_key_types: ["not_signed_curve25519"],
|
||||
});
|
||||
expect(client!.crypto!.getNeedsNewFallback()).toEqual(true);
|
||||
expect(client!.crypto!.processKeyCounts).toHaveBeenCalledWith({ signed_curve25519: 42 }, [
|
||||
"signed_curve25519",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -734,7 +708,7 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
});
|
||||
globalData = client!.getAccountData(globalType)!;
|
||||
expect(globalData).toBeDefined();
|
||||
expect(globalData).toBeTruthy();
|
||||
expect(globalData.getContent()).toEqual(globalContent);
|
||||
});
|
||||
|
||||
@@ -755,6 +729,7 @@ describe("SlidingSyncSdk", () => {
|
||||
foo: "bar",
|
||||
};
|
||||
const roomType = "test";
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
[roomId]: [
|
||||
@@ -766,9 +741,9 @@ describe("SlidingSyncSdk", () => {
|
||||
},
|
||||
});
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const event = room.getAccountData(roomType)!;
|
||||
expect(event).toBeDefined();
|
||||
expect(event).toBeTruthy();
|
||||
expect(event.getContent()).toEqual(roomContent);
|
||||
});
|
||||
|
||||
@@ -891,11 +866,9 @@ describe("SlidingSyncSdk", () => {
|
||||
const evType = ev.getType();
|
||||
expect(seen[evType]).toBeFalsy();
|
||||
seen[evType] = true;
|
||||
if (evType === "m.key.verification.start" || evType === "m.key.verification.request") {
|
||||
expect(ev.isCancelled()).toEqual(true);
|
||||
} else {
|
||||
expect(ev.isCancelled()).toEqual(false);
|
||||
}
|
||||
expect(ev.isCancelled()).toEqual(
|
||||
evType === "m.key.verification.start" || evType === "m.key.verification.request",
|
||||
);
|
||||
});
|
||||
ext.onResponse({
|
||||
next_batch: "45678",
|
||||
@@ -956,8 +929,9 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
initial: true,
|
||||
});
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
@@ -997,7 +971,7 @@ describe("SlidingSyncSdk", () => {
|
||||
initial: true,
|
||||
});
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
@@ -1090,12 +1064,13 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
initial: true,
|
||||
});
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getReadReceiptForUserId(alice, true)).toBeNull();
|
||||
ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567));
|
||||
const receipt = room.getReadReceiptForUserId(alice);
|
||||
expect(receipt).toBeDefined();
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(lastEvent.event_id);
|
||||
expect(receipt?.data.ts).toEqual(1234567);
|
||||
expect(receipt?.data.thread_id).toBeFalsy();
|
||||
|
||||
@@ -1161,11 +1161,6 @@ describe("SlidingSync", () => {
|
||||
httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]);
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]);
|
||||
|
||||
const okPromise = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let txnId: string | undefined;
|
||||
httpBackend!
|
||||
@@ -1180,8 +1175,12 @@ describe("SlidingSync", () => {
|
||||
txn_id: txnId,
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
await okPromise;
|
||||
await Promise.all([
|
||||
expect(failPromise).rejects.toEqual(gotTxnIds[0]),
|
||||
expect(failPromise2).rejects.toEqual(gotTxnIds[1]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
okPromise,
|
||||
]);
|
||||
|
||||
expect(txnId).toBeDefined();
|
||||
});
|
||||
@@ -1200,7 +1199,6 @@ describe("SlidingSync", () => {
|
||||
|
||||
// attach rejection handlers now else if we do it later Jest treats that as an unhandled rejection
|
||||
// which is a fail.
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]);
|
||||
|
||||
const C = slidingSync.setListRanges("a", [[0, 20]]);
|
||||
let pendingC = true;
|
||||
@@ -1217,9 +1215,12 @@ describe("SlidingSync", () => {
|
||||
txn_id: gotTxnIds[1],
|
||||
};
|
||||
});
|
||||
await httpBackend!.flushAllExpected();
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]); // B is resolved
|
||||
await Promise.all([
|
||||
expect(A).rejects.toEqual(gotTxnIds[0]),
|
||||
httpBackend!.flushAllExpected(),
|
||||
// A is rejected, see above
|
||||
expect(B).resolves.toEqual(gotTxnIds[1]), // B is resolved
|
||||
]);
|
||||
expect(pendingC).toBe(true); // C is pending still
|
||||
});
|
||||
it("should do nothing for unknown txn_ids", async () => {
|
||||
@@ -1698,7 +1699,7 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
|
||||
function timeout(delayMs: number, reason: string): { promise: Promise<never>; cancel: () => void } {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return {
|
||||
promise: new Promise((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
|
||||
+1
-1
@@ -23,5 +23,5 @@ try {
|
||||
global.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available");
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import DOMException from "domexception";
|
||||
|
||||
global.DOMException = DOMException;
|
||||
global.DOMException = DOMException as typeof global.DOMException;
|
||||
|
||||
jest.mock("../src/http-api/utils", () => ({
|
||||
...jest.requireActual("../src/http-api/utils"),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright 2023 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 debugFunc from "debug";
|
||||
import { Debugger } from "debug";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
|
||||
|
||||
/** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys
|
||||
*
|
||||
* Common interface implemented by {@link TestClient} and {@link E2EKeyReceiver}
|
||||
*/
|
||||
export interface IE2EKeyReceiver {
|
||||
/**
|
||||
* get the uploaded ed25519 device key
|
||||
*
|
||||
* @returns base64 device key
|
||||
*/
|
||||
getSigningKey(): string;
|
||||
|
||||
/**
|
||||
* get the uploaded curve25519 device key
|
||||
*
|
||||
* @returns base64 device key
|
||||
*/
|
||||
getDeviceKey(): string;
|
||||
|
||||
/**
|
||||
* Wait for one-time-keys to be uploaded, then return them.
|
||||
*
|
||||
* @returns Promise for the one-time keys
|
||||
*/
|
||||
awaitOneTimeKeyUpload(): Promise<Record<string, IOneTimeKey>>;
|
||||
}
|
||||
|
||||
/** E2EKeyReceiver: An object which intercepts `/keys/uploads` fetches via fetch-mock.
|
||||
*
|
||||
* It stashes the uploaded keys for use elsewhere in the tests.
|
||||
*/
|
||||
export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
private readonly debug: Debugger;
|
||||
|
||||
private deviceKeys: IDeviceKeys | null = null;
|
||||
private oneTimeKeys: Record<string, IOneTimeKey> = {};
|
||||
private readonly oneTimeKeysPromise: Promise<void>;
|
||||
|
||||
/**
|
||||
* Construct a new E2EKeyReceiver.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl.
|
||||
* Only /upload requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`);
|
||||
|
||||
// set up a listener for /keys/upload.
|
||||
this.oneTimeKeysPromise = new Promise((resolveOneTimeKeys) => {
|
||||
const listener = (url: string, options: RequestInit) =>
|
||||
this.onKeyUploadRequest(resolveOneTimeKeys, options);
|
||||
|
||||
// catch both r0 and v3 variants
|
||||
fetchMock.post(new URL("/_matrix/client/r0/keys/upload", homeserverUrl).toString(), listener);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
|
||||
});
|
||||
}
|
||||
|
||||
private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise<object> {
|
||||
const content = JSON.parse(options.body as string);
|
||||
|
||||
// device keys may only be uploaded once
|
||||
if (content.device_keys && Object.keys(content.device_keys).length > 0) {
|
||||
if (this.deviceKeys) {
|
||||
throw new Error("Application attempted to upload E2E device keys multiple times");
|
||||
}
|
||||
this.debug(`received device keys`);
|
||||
this.deviceKeys = content.device_keys;
|
||||
}
|
||||
|
||||
if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) {
|
||||
// this is a one-time-key upload
|
||||
|
||||
// if we already have a batch of one-time keys, then slow-roll the response,
|
||||
// otherwise the client ends up tight-looping one-time-key-uploads and filling the logs with junk.
|
||||
if (Object.keys(this.oneTimeKeys).length > 0) {
|
||||
this.debug(`received second batch of one-time keys: blocking response`);
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
this.debug(`received ${Object.keys(content.one_time_keys).length} one-time keys`);
|
||||
Object.assign(this.oneTimeKeys, content.one_time_keys);
|
||||
onOnTimeKeysUploaded();
|
||||
}
|
||||
|
||||
return {
|
||||
one_time_key_counts: {
|
||||
signed_curve25519: Object.keys(this.oneTimeKeys).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the uploaded Ed25519 key
|
||||
*
|
||||
* If device keys have not yet been uploaded, throws an error
|
||||
*/
|
||||
public getSigningKey(): string {
|
||||
if (!this.deviceKeys) {
|
||||
throw new Error("Device keys not yet uploaded");
|
||||
}
|
||||
const keyIds = Object.keys(this.deviceKeys.keys).filter((v) => v.startsWith("ed25519:"));
|
||||
if (keyIds.length != 1) {
|
||||
throw new Error(`Expected exactly 1 ed25519 key uploaded, got ${keyIds}`);
|
||||
}
|
||||
return this.deviceKeys.keys[keyIds[0]];
|
||||
}
|
||||
|
||||
/** Get the uploaded Curve25519 key
|
||||
*
|
||||
* If device keys have not yet been uploaded, throws an error
|
||||
*/
|
||||
public getDeviceKey(): string {
|
||||
if (!this.deviceKeys) {
|
||||
throw new Error("Device keys not yet uploaded");
|
||||
}
|
||||
const keyIds = Object.keys(this.deviceKeys.keys).filter((v) => v.startsWith("curve25519:"));
|
||||
if (keyIds.length != 1) {
|
||||
throw new Error(`Expected exactly 1 curve25519 key uploaded, got ${keyIds}`);
|
||||
}
|
||||
return this.deviceKeys.keys[keyIds[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* If the device keys have already been uploaded, return them. Else return null.
|
||||
*/
|
||||
public getUploadedDeviceKeys(): IDeviceKeys | null {
|
||||
return this.deviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async awaitOneTimeKeyUpload(): Promise<Record<string, IOneTimeKey>> {
|
||||
await this.oneTimeKeysPromise;
|
||||
return this.oneTimeKeys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright 2023 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 fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MapWithDefault } from "../../src/utils";
|
||||
import { IDownloadKeyResult } from "../../src";
|
||||
import { IDeviceKeys } from "../../src/@types/crypto";
|
||||
import { E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
|
||||
/**
|
||||
* An object which intercepts `/keys/query` fetches via fetch-mock.
|
||||
*/
|
||||
export class E2EKeyResponder {
|
||||
private deviceKeysByUserByDevice = new MapWithDefault<string, Map<string, any>>(() => new Map());
|
||||
private e2eKeyReceiversByUser = new Map<string, E2EKeyReceiver>();
|
||||
private masterKeysByUser: Record<string, any> = {};
|
||||
private selfSigningKeysByUser: Record<string, any> = {};
|
||||
private userSigningKeysByUser: Record<string, any> = {};
|
||||
|
||||
/**
|
||||
* Construct a new E2EKeyResponder.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/query` requests for the given homeserverUrl.
|
||||
* Only /query requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
// set up a listener for /keys/query.
|
||||
const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options);
|
||||
// catch both r0 and v3 variants
|
||||
fetchMock.post(new URL("/_matrix/client/r0/keys/query", homeserverUrl).toString(), listener);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener);
|
||||
}
|
||||
|
||||
private onKeyQueryRequest(options: RequestInit) {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const usersToReturn = Object.keys(content["device_keys"]);
|
||||
const response = {
|
||||
device_keys: {} as { [userId: string]: any },
|
||||
master_keys: {} as { [userId: string]: any },
|
||||
self_signing_keys: {} as { [userId: string]: any },
|
||||
user_signing_keys: {} as { [userId: string]: any },
|
||||
failures: {} as { [serverName: string]: any },
|
||||
};
|
||||
for (const user of usersToReturn) {
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] = Object.fromEntries(userKeys.entries());
|
||||
}
|
||||
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
||||
if (e2eKeyReceiver !== undefined) {
|
||||
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
||||
if (deviceKeys !== null) {
|
||||
response.device_keys[user] ??= {};
|
||||
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.masterKeysByUser.hasOwnProperty(user)) {
|
||||
response.master_keys[user] = this.masterKeysByUser[user];
|
||||
}
|
||||
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
|
||||
}
|
||||
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed
|
||||
*
|
||||
* @param keys - device keys for this device.
|
||||
*/
|
||||
public addDeviceKeys(keys: IDeviceKeys) {
|
||||
this.deviceKeysByUserByDevice.getOrCreate(keys.user_id).set(keys.device_id, keys);
|
||||
}
|
||||
|
||||
/** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed
|
||||
*
|
||||
* @param data cross-signing data
|
||||
*/
|
||||
public addCrossSigningData(
|
||||
data: Pick<IDownloadKeyResult, "master_keys" | "self_signing_keys" | "user_signing_keys">,
|
||||
) {
|
||||
Object.assign(this.masterKeysByUser, data.master_keys);
|
||||
Object.assign(this.selfSigningKeysByUser, data.self_signing_keys);
|
||||
Object.assign(this.userSigningKeysByUser, data.user_signing_keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an E2EKeyReceiver to poll for uploaded keys
|
||||
*
|
||||
* Any keys which have been uploaded to the given `E2EKeyReceiver` at the time of the `/keys/query` request will
|
||||
* be added to the response.
|
||||
*
|
||||
* @param e2eKeyReceiver
|
||||
*/
|
||||
public addKeyReceiver(userId: string, e2eKeyReceiver: E2EKeyReceiver) {
|
||||
this.e2eKeyReceiversByUser.set(userId, e2eKeyReceiver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Copyright 2023 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 debugFunc from "debug";
|
||||
import { Debugger } from "debug";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { MockResponse } from "fetch-mock";
|
||||
|
||||
/** Interface implemented by classes that intercept `/sync` requests from test clients
|
||||
*
|
||||
* Common interface implemented by {@link TestClient} and {@link SyncResponder}
|
||||
*/
|
||||
export interface ISyncResponder {
|
||||
/** Next time we see a sync request (or immediately, if there is one waiting), send the given response
|
||||
*
|
||||
* @param response - response to /sync request
|
||||
*/
|
||||
sendOrQueueSyncResponse(response: object): void;
|
||||
}
|
||||
|
||||
enum SyncResponderState {
|
||||
IDLE,
|
||||
WAITING_FOR_REQUEST,
|
||||
WAITING_FOR_RESPONSE,
|
||||
}
|
||||
|
||||
/** SyncResponder: An object which intercepts `/sync` fetches via fetch-mock.
|
||||
*
|
||||
* Two modes are possible:
|
||||
* * A response can be queued up; the next call to `/sync` will return it.
|
||||
* * If a call to `/sync` arrives before a response is queued, it will block until a call to {@link #sendOrQueueSyncResponse}.
|
||||
*/
|
||||
export class SyncResponder implements ISyncResponder {
|
||||
private readonly debug: Debugger;
|
||||
private state: SyncResponderState = SyncResponderState.IDLE;
|
||||
|
||||
/*
|
||||
* properties that are only valid in WAITING_FOR_REQUEST
|
||||
*/
|
||||
|
||||
/** the response to be sent when the request is made */
|
||||
private pendingResponse: object | null = null;
|
||||
|
||||
/*
|
||||
* properties that are only valid in WAITING_FOR_RESPONSE
|
||||
*/
|
||||
|
||||
/** a callback to be called with a response once one is registered.
|
||||
*
|
||||
* It will release the /sync request and update the state.
|
||||
*/
|
||||
private onResponseReceived: ((response: object) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Construct a new SyncResponder.
|
||||
*
|
||||
* It will immediately register an intercept of `/sync` requests for the given homeserverUrl.
|
||||
* Only /sync requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have overlapping /sync requests.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
this.debug = debugFunc(`sync-responder:[${homeserverUrl}]`);
|
||||
fetchMock.get("begin:" + new URL("/_matrix/client/r0/sync?", homeserverUrl).toString(), (_url, _options) =>
|
||||
this.onSyncRequest(),
|
||||
);
|
||||
}
|
||||
|
||||
private async onSyncRequest(): Promise<MockResponse> {
|
||||
switch (this.state) {
|
||||
case SyncResponderState.IDLE: {
|
||||
this.debug("Got /sync request: waiting for response to be ready");
|
||||
const res = await new Promise<object>((resolve) => {
|
||||
this.onResponseReceived = resolve;
|
||||
this.state = SyncResponderState.WAITING_FOR_RESPONSE;
|
||||
});
|
||||
this.debug("Responding to /sync");
|
||||
this.state = SyncResponderState.IDLE;
|
||||
this.onResponseReceived = null;
|
||||
return res;
|
||||
}
|
||||
|
||||
case SyncResponderState.WAITING_FOR_REQUEST: {
|
||||
this.debug("Got /sync request: responding immediately with queued response");
|
||||
const res = this.pendingResponse!;
|
||||
this.state = SyncResponderState.IDLE;
|
||||
this.pendingResponse = null;
|
||||
return res;
|
||||
}
|
||||
|
||||
default:
|
||||
// we must already be in WAITING_FOR_RESPONSE, ie we already have a /sync request in progress
|
||||
throw new Error(`Got unexpected /sync request in state ${this.state}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Next time we see a sync request (or immediately, if there is one waiting), send the given response
|
||||
*
|
||||
* @param response - response to /sync request
|
||||
*/
|
||||
public sendOrQueueSyncResponse(response: object): void {
|
||||
switch (this.state) {
|
||||
case SyncResponderState.IDLE:
|
||||
this.pendingResponse = response;
|
||||
this.state = SyncResponderState.WAITING_FOR_REQUEST;
|
||||
break;
|
||||
|
||||
case SyncResponderState.WAITING_FOR_RESPONSE:
|
||||
this.onResponseReceived!(response);
|
||||
break;
|
||||
|
||||
default:
|
||||
// we already have a response queued
|
||||
throw new Error(`Cannot queue more than one /sync response`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export const getMockClientWithEventEmitter = (
|
||||
*/
|
||||
export const mockClientMethodsUser = (userId = "@alice:domain") => ({
|
||||
getUserId: jest.fn().mockReturnValue(userId),
|
||||
getSafeUserId: jest.fn().mockReturnValue(userId),
|
||||
getUser: jest.fn().mockReturnValue(new User(userId)),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright 2023 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 fetchMock from "fetch-mock-jest";
|
||||
|
||||
/**
|
||||
* Mock out the endpoints that the js-sdk calls when we call `MatrixClient.start()`.
|
||||
*
|
||||
* @param homeserverUrl - the homeserver url for the client under test
|
||||
*/
|
||||
export function mockInitialApiRequests(homeserverUrl: string) {
|
||||
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
|
||||
fetchMock.getOnce(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
|
||||
fetchMock.postOnce(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
|
||||
filter_id: "fid",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* Return 404 error for `GET _matrix/client/r0/user/:userId/account_data/:type` request
|
||||
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
|
||||
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
|
||||
*/
|
||||
export function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2023 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 { OidcClientConfig } from "../../src";
|
||||
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
|
||||
|
||||
/**
|
||||
* Makes a valid OidcClientConfig with minimum valid values
|
||||
* @param issuer used as the base for all other urls
|
||||
* @returns OidcClientConfig
|
||||
*/
|
||||
export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClientConfig => {
|
||||
const metadata = mockOpenIdConfiguration(issuer);
|
||||
|
||||
return {
|
||||
issuer,
|
||||
account: issuer + "account",
|
||||
registrationEndpoint: metadata.registration_endpoint,
|
||||
authorizationEndpoint: metadata.authorization_endpoint,
|
||||
tokenEndpoint: metadata.token_endpoint,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Useful for mocking <issuer>/.well-known/openid-configuration
|
||||
* @param issuer used as the base for all other urls
|
||||
* @returns ValidatedIssuerMetadata
|
||||
*/
|
||||
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
|
||||
issuer,
|
||||
revocation_endpoint: issuer + "revoke",
|
||||
token_endpoint: issuer + "token",
|
||||
authorization_endpoint: issuer + "auth",
|
||||
registration_endpoint: issuer + "registration",
|
||||
jwks_uri: issuer + "jwks",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
/env
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
#!/bin/env python
|
||||
#
|
||||
# Copyright 2023 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.
|
||||
|
||||
"""
|
||||
This file is a Python script to generate test data for crypto tests.
|
||||
|
||||
To run it:
|
||||
|
||||
python -m venv env
|
||||
./env/bin/pip install cryptography canonicaljson
|
||||
./env/bin/python generate-test-data.py > index.ts
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from canonicaljson import encode_canonical_json
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
# input data
|
||||
TEST_USER_ID = "@alice:localhost"
|
||||
TEST_DEVICE_ID = "test_device"
|
||||
TEST_ROOM_ID = "!room:id"
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale"
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"
|
||||
|
||||
# Private key for secure key backup. There are some sessions encrypted with this key in megolm-backup.spec.ts
|
||||
B64_BACKUP_DECRYPTION_KEY = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="
|
||||
|
||||
def main() -> None:
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
TEST_DEVICE_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_public_key = encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
device_data = {
|
||||
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
"device_id": TEST_DEVICE_ID,
|
||||
"keys": {
|
||||
f"curve25519:{TEST_DEVICE_ID}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
f"ed25519:{TEST_DEVICE_ID}": b64_public_key,
|
||||
},
|
||||
"signatures": {TEST_USER_ID: {}},
|
||||
"user_id": TEST_USER_ID,
|
||||
}
|
||||
|
||||
device_data["signatures"][TEST_USER_ID][f"ed25519:{TEST_DEVICE_ID}"] = sign_json(
|
||||
device_data, private_key
|
||||
)
|
||||
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
b64_master_private_key = encode_base64(MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES)
|
||||
|
||||
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_self_signing_public_key = encode_base64(
|
||||
self_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
b64_self_signing_private_key = encode_base64(SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES)
|
||||
|
||||
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_user_signing_public_key = encode_base64(
|
||||
user_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
b64_user_signing_private_key = encode_base64(USER_CROSS_SIGNING_PRIVATE_KEY_BYTES)
|
||||
|
||||
backup_decryption_key = x25519.X25519PrivateKey.from_private_bytes(
|
||||
base64.b64decode(B64_BACKUP_DECRYPTION_KEY)
|
||||
)
|
||||
b64_backup_public_key = encode_base64(
|
||||
backup_decryption_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
backup_data = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": b64_backup_public_key,
|
||||
},
|
||||
}
|
||||
# sign with our device key
|
||||
sig = sign_json(backup_data["auth_data"], private_key)
|
||||
backup_data["auth_data"]["signatures"] = {
|
||||
TEST_USER_ID: {f"ed25519:{TEST_DEVICE_ID}": sig}
|
||||
}
|
||||
|
||||
print(
|
||||
f"""\
|
||||
/* Test data for cryptography tests
|
||||
*
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult }} from "../../../src";
|
||||
import {{ KeyBackupInfo }} from "../../../src/crypto-api";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
export const TEST_USER_ID = "{TEST_USER_ID}";
|
||||
export const TEST_DEVICE_ID = "{TEST_DEVICE_ID}";
|
||||
export const TEST_ROOM_ID = "{TEST_ROOM_ID}";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
|
||||
|
||||
/** base64-encoded private master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_master_private_key}";
|
||||
|
||||
/** base64-encoded public self cross-signing key */
|
||||
export const SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_self_signing_public_key}";
|
||||
|
||||
/** base64-encoded private self signing cross-signing key */
|
||||
export const SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_self_signing_private_key}";
|
||||
|
||||
/** base64-encoded public user cross-signing key */
|
||||
export const USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_user_signing_public_key}";
|
||||
|
||||
/** base64-encoded private user signing cross-signing key */
|
||||
export const USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "{b64_user_signing_private_key}";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
json.dumps(build_cross_signing_keys_data(), indent=4)
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "{ B64_BACKUP_DECRYPTION_KEY }";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
|
||||
export const SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
|
||||
""",
|
||||
end="",
|
||||
)
|
||||
|
||||
|
||||
def build_cross_signing_keys_data() -> dict:
|
||||
"""Build the signed cross-signing-keys data for return from /keys/query"""
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_self_signing_public_key = encode_base64(
|
||||
self_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_user_signing_public_key = encode_base64(
|
||||
user_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
# create without signatures initially
|
||||
cross_signing_keys_data = {
|
||||
"master_keys": {
|
||||
TEST_USER_ID: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_master_public_key}": b64_master_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"usage": ["master"],
|
||||
}
|
||||
},
|
||||
"self_signing_keys": {
|
||||
TEST_USER_ID: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_self_signing_public_key}": b64_self_signing_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"usage": ["self_signing"],
|
||||
},
|
||||
},
|
||||
"user_signing_keys": {
|
||||
TEST_USER_ID: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_user_signing_public_key}": b64_user_signing_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"usage": ["user_signing"],
|
||||
},
|
||||
},
|
||||
}
|
||||
# sign the sub-keys with the master
|
||||
for k in ["self_signing_keys", "user_signing_keys"]:
|
||||
to_sign = cross_signing_keys_data[k][TEST_USER_ID]
|
||||
sig = sign_json(to_sign, master_private_key)
|
||||
to_sign["signatures"] = {
|
||||
TEST_USER_ID: {f"ed25519:{b64_master_public_key}": sig}
|
||||
}
|
||||
|
||||
return cross_signing_keys_data
|
||||
|
||||
|
||||
def encode_base64(input_bytes: bytes) -> str:
|
||||
"""Encode with unpadded base64"""
|
||||
output_bytes = base64.b64encode(input_bytes)
|
||||
output_string = output_bytes.decode("ascii")
|
||||
return output_string.rstrip("=")
|
||||
|
||||
|
||||
def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
"""
|
||||
Sign the given json object
|
||||
|
||||
Returns the base64-encoded signature of signing `input` following the Matrix
|
||||
JSON signature algorithm [1]
|
||||
|
||||
[1]: https://spec.matrix.org/v1.7/appendices/#signing-details
|
||||
"""
|
||||
signatures = json_object.pop("signatures", {})
|
||||
unsigned = json_object.pop("unsigned", None)
|
||||
|
||||
signature = private_key.sign(encode_canonical_json(json_object))
|
||||
signature_base64 = encode_base64(signature)
|
||||
|
||||
json_object["signatures"] = signatures
|
||||
if unsigned is not None:
|
||||
json_object["unsigned"] = unsigned
|
||||
|
||||
return signature_base64
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Test data for cryptography tests
|
||||
*
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import { IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import { IDownloadKeyResult } from "../../../src";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
export const TEST_USER_ID = "@alice:localhost";
|
||||
export const TEST_DEVICE_ID = "test_device";
|
||||
export const TEST_ROOM_ID = "!room:id";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU";
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
|
||||
"algorithms": [
|
||||
"m.olm.v1.curve25519-aes-sha2",
|
||||
"m.megolm.v1.aes-sha2"
|
||||
],
|
||||
"device_id": "test_device",
|
||||
"keys": {
|
||||
"curve25519:test_device": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
"ed25519:test_device": "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "LmQC/yAUZJmkxZ+3L0nEwvtVWOzjqQqADWBhk+C47SPaFYHeV+E291mgXaSCJVeGltX+HC49Aw7nb6ga7sw0Aw"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY";
|
||||
|
||||
/** base64-encoded private master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "ZG95b3VzcGVha3doYWFhYWFhYWFhYWFhYWFhYWFhbGU";
|
||||
|
||||
/** base64-encoded public self cross-signing key */
|
||||
export const SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY";
|
||||
|
||||
/** base64-encoded private self signing cross-signing key */
|
||||
export const SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "c2VsZnNlbGZzZWxmc2VsZnNlbGZzZWxmc2VsZnNlbGY";
|
||||
|
||||
/** base64-encoded public user cross-signing key */
|
||||
export const USER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY";
|
||||
|
||||
/** base64-encoded private user signing cross-signing key */
|
||||
export const USER_CROSS_SIGNING_PRIVATE_KEY_BASE64 = "dXNlcnVzZXJ1c2VydXNlcnVzZXJ1c2VydXNlcnVzZXI";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
"master_keys": {
|
||||
"@alice:localhost": {
|
||||
"keys": {
|
||||
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"usage": [
|
||||
"master"
|
||||
]
|
||||
}
|
||||
},
|
||||
"self_signing_keys": {
|
||||
"@alice:localhost": {
|
||||
"keys": {
|
||||
"ed25519:aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY": "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"usage": [
|
||||
"self_signing"
|
||||
],
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "XfhYEhZmOs8BJdb3viatILBZ/bElsHXEW28V4tIaY5CxrBR0YOym3yZHWmRmypXessHZAKOhZn3yBMXzdajyCw"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_signing_keys": {
|
||||
"@alice:localhost": {
|
||||
"keys": {
|
||||
"ed25519:g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY": "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"usage": [
|
||||
"user_signing"
|
||||
],
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "6AkD1XM2H0/ebgP9oBdMKNeft7uxsrb0XN1CsjjHgeZCvCTMmv3BHlLiT/Hzy4fe8H+S1tr484dcXN/PIdnfDA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded backup decryption (private) key */
|
||||
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
|
||||
|
||||
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
|
||||
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
|
||||
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
"version": "1",
|
||||
"auth_data": {
|
||||
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,9 +6,19 @@ import "../olm-loader";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
IJoinedRoom,
|
||||
IPusher,
|
||||
ISyncResponse,
|
||||
MatrixClient,
|
||||
MsgType,
|
||||
RelationType,
|
||||
} from "../../src";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
import { TEST_ROOM_ID } from "./test-data";
|
||||
|
||||
/**
|
||||
* Return a promise that is resolved when the client next emits a
|
||||
@@ -39,6 +49,61 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
|
||||
* @param roomMembers
|
||||
* @param roomId
|
||||
*
|
||||
* @returns the sync response
|
||||
*/
|
||||
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
|
||||
const roomResponse: IJoinedRoom = {
|
||||
summary: {
|
||||
"m.heroes": [],
|
||||
"m.joined_member_count": roomMembers.length,
|
||||
"m.invited_member_count": roomMembers.length,
|
||||
},
|
||||
state: {
|
||||
events: [
|
||||
mkEventCustom({
|
||||
sender: roomMembers[0],
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
timeline: {
|
||||
events: [],
|
||||
prev_batch: "",
|
||||
},
|
||||
ephemeral: { events: [] },
|
||||
account_data: { events: [] },
|
||||
unread_notifications: {},
|
||||
};
|
||||
|
||||
for (let i = 0; i < roomMembers.length; i++) {
|
||||
roomResponse.state.events.push(
|
||||
mkMembershipCustom({
|
||||
membership: "join",
|
||||
sender: roomMembers[i],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
next_batch: "1",
|
||||
rooms: {
|
||||
join: { [roomId]: roomResponse },
|
||||
invite: {},
|
||||
leave: {},
|
||||
},
|
||||
account_data: { events: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spy for an object and automatically spy its methods.
|
||||
* @param constr - The class constructor (used with 'new')
|
||||
@@ -258,6 +323,9 @@ export interface IMessageOpts {
|
||||
* @param opts.user - The user ID for the event.
|
||||
* @param opts.msg - Optional. The content.body for the event.
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param opts.relatesTo - An IEventRelation relating this to another event.
|
||||
* @param opts.ts - The timestamp of the event.
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
|
||||
* @returns The event
|
||||
*/
|
||||
@@ -297,6 +365,7 @@ interface IReplyMessageOpts extends IMessageOpts {
|
||||
* @param opts.room - The room ID for the event.
|
||||
* @param opts.user - The user ID for the event.
|
||||
* @param opts.msg - Optional. The content.body for the event.
|
||||
* @param opts.ts - The timestamp of the event.
|
||||
* @param opts.replyToMessage - The replied message
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
|
||||
@@ -330,6 +399,73 @@ export function mkReplyMessage(
|
||||
return mkEvent(eventOpts, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reaction event.
|
||||
*
|
||||
* @param target - the event we are reacting to.
|
||||
* @param client - the MatrixClient
|
||||
* @param userId - the userId of the sender
|
||||
* @param roomId - the id of the room we are in
|
||||
* @param ts - The timestamp of the event.
|
||||
* @returns The event
|
||||
*/
|
||||
export function mkReaction(
|
||||
target: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
ts?: number,
|
||||
): MatrixEvent {
|
||||
return mkEvent(
|
||||
{
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: target.getId()!,
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
ts,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
export function mkEdit(
|
||||
target: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
msg?: string,
|
||||
ts?: number,
|
||||
) {
|
||||
msg = msg ?? `Edit of ${target.getId()}`;
|
||||
return mkEvent(
|
||||
{
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"body": `* ${msg}`,
|
||||
"m.new_content": {
|
||||
body: msg,
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: target.getId()!,
|
||||
},
|
||||
},
|
||||
ts,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock implementation of webstorage
|
||||
*/
|
||||
@@ -375,17 +511,17 @@ export async function awaitDecryption(
|
||||
// already
|
||||
if (event.getClearContent() !== null) {
|
||||
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
|
||||
logger.log(`${Date.now()} event ${event.getId()} got decryption error; waiting`);
|
||||
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
} else {
|
||||
logger.log(`${Date.now()} event ${event.getId()} is not yet decrypted; waiting`);
|
||||
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev) => {
|
||||
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RelationType } from "../../src/@types/event";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
import { mkMessage } from "./test-utils";
|
||||
|
||||
export const makeThreadEvent = ({
|
||||
@@ -34,7 +34,7 @@ export const makeThreadEvent = ({
|
||||
...props,
|
||||
relatesTo: {
|
||||
event_id: rootEventId,
|
||||
rel_type: "m.thread",
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
["m.in_reply_to"]: {
|
||||
event_id: replyToEventId,
|
||||
},
|
||||
@@ -115,6 +115,26 @@ type MakeThreadProps = {
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
type MakeThreadResult = {
|
||||
/**
|
||||
* Thread model
|
||||
*/
|
||||
thread: Thread;
|
||||
/**
|
||||
* Thread root event
|
||||
*/
|
||||
rootEvent: MatrixEvent;
|
||||
/**
|
||||
* Events added to the thread
|
||||
*/
|
||||
events: MatrixEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a new thread in a room by creating a message as thread root.
|
||||
* Also creates a Thread model and adds it to the room.
|
||||
* Does not insert the messages into a timeline.
|
||||
*/
|
||||
export const mkThread = ({
|
||||
room,
|
||||
client,
|
||||
@@ -122,7 +142,7 @@ export const mkThread = ({
|
||||
participantUserIds,
|
||||
length = 2,
|
||||
ts = 1,
|
||||
}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => {
|
||||
}: MakeThreadProps): MakeThreadResult => {
|
||||
const { rootEvent, events } = makeThreadEvents({
|
||||
roomId: room.roomId,
|
||||
authorId,
|
||||
@@ -137,7 +157,7 @@ export const mkThread = ({
|
||||
room?.reEmitter.reEmit(evt, [MatrixEventEvent.BeforeRedaction]);
|
||||
}
|
||||
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, events, true);
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, [rootEvent, ...events], true);
|
||||
|
||||
return { thread, rootEvent, events };
|
||||
};
|
||||
|
||||
+228
-8
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022 - 2023 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.
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
RoomState,
|
||||
RoomStateEvent,
|
||||
RoomStateEventHandlerMap,
|
||||
SendToDeviceContentMap,
|
||||
} from "../../src";
|
||||
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
|
||||
import { ReEmitter } from "../../src/ReEmitter";
|
||||
@@ -122,6 +123,7 @@ export class MockRTCPeerConnection {
|
||||
public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void;
|
||||
public iceConnectionStateChangeListener?: () => void;
|
||||
public onTrackListener?: (e: RTCTrackEvent) => void;
|
||||
public onDataChannelListener?: (ev: RTCDataChannelEvent) => void;
|
||||
public needsNegotiation = false;
|
||||
public readyToNegotiate: Promise<void>;
|
||||
private onReadyToNegotiate?: () => void;
|
||||
@@ -167,6 +169,8 @@ export class MockRTCPeerConnection {
|
||||
this.iceConnectionStateChangeListener = listener;
|
||||
} else if (type == "track") {
|
||||
this.onTrackListener = listener;
|
||||
} else if (type == "datachannel") {
|
||||
this.onDataChannelListener = listener;
|
||||
}
|
||||
}
|
||||
public createDataChannel(label: string, opts: RTCDataChannelInit) {
|
||||
@@ -231,6 +235,12 @@ export class MockRTCPeerConnection {
|
||||
this.negotiationNeededListener();
|
||||
}
|
||||
}
|
||||
|
||||
public triggerIncomingDataChannel(): void {
|
||||
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
|
||||
}
|
||||
|
||||
public restartIce(): void {}
|
||||
}
|
||||
|
||||
export class MockRTCRtpSender {
|
||||
@@ -443,13 +453,13 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
>();
|
||||
public sendToDevice = jest.fn<
|
||||
Promise<{}>,
|
||||
[
|
||||
eventType: string,
|
||||
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
|
||||
txnId?: string,
|
||||
]
|
||||
[eventType: string, contentMap: SendToDeviceContentMap, txnId?: string]
|
||||
>();
|
||||
|
||||
public isInitialSyncComplete(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public getMediaHandler(): MediaHandler {
|
||||
return this.mediaHandler.typed();
|
||||
}
|
||||
@@ -476,7 +486,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
|
||||
public getRoom = jest.fn();
|
||||
|
||||
public supportsExperimentalThreads(): boolean {
|
||||
public supportsThreads(): boolean {
|
||||
return true;
|
||||
}
|
||||
public async decryptEventIfNeeded(): Promise<void> {}
|
||||
@@ -498,18 +508,22 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
|
||||
public state = CallState.Ringing;
|
||||
public opponentUserId = FAKE_USER_ID_1;
|
||||
public opponentDeviceId = FAKE_DEVICE_ID_1;
|
||||
public opponentSessionId = FAKE_SESSION_ID_1;
|
||||
public opponentMember = { userId: this.opponentUserId };
|
||||
public callId = "1";
|
||||
public localUsermediaFeed = {
|
||||
setAudioVideoMuted: jest.fn<void, [boolean, boolean]>(),
|
||||
isAudioMuted: jest.fn().mockReturnValue(false),
|
||||
isVideoMuted: jest.fn().mockReturnValue(false),
|
||||
stream: new MockMediaStream("stream"),
|
||||
};
|
||||
} as unknown as CallFeed;
|
||||
public remoteUsermediaFeed?: CallFeed;
|
||||
public remoteScreensharingFeed?: CallFeed;
|
||||
|
||||
public reject = jest.fn<void, []>();
|
||||
public answerWithCallFeeds = jest.fn<void, [CallFeed[]]>();
|
||||
public hangup = jest.fn<void, []>();
|
||||
public initStats = jest.fn<void, []>();
|
||||
|
||||
public sendMetadataUpdate = jest.fn<void, []>();
|
||||
|
||||
@@ -521,6 +535,14 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
|
||||
return this.opponentDeviceId;
|
||||
}
|
||||
|
||||
public getOpponentSessionId(): string | undefined {
|
||||
return this.opponentSessionId;
|
||||
}
|
||||
|
||||
public getLocalFeeds(): CallFeed[] {
|
||||
return [this.localUsermediaFeed];
|
||||
}
|
||||
|
||||
public typed(): MatrixCall {
|
||||
return this as unknown as MatrixCall;
|
||||
}
|
||||
@@ -581,6 +603,7 @@ export function makeMockGroupCallStateEvent(
|
||||
"m.type": GroupCallType.Video,
|
||||
"m.intent": GroupCallIntent.Prompt,
|
||||
},
|
||||
redacted?: boolean,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix),
|
||||
@@ -588,6 +611,7 @@ export function makeMockGroupCallStateEvent(
|
||||
getTs: jest.fn().mockReturnValue(0),
|
||||
getContent: jest.fn().mockReturnValue(content),
|
||||
getStateKey: jest.fn().mockReturnValue(groupCallId),
|
||||
isRedacted: jest.fn().mockReturnValue(redacted ?? false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
@@ -600,3 +624,199 @@ export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: s
|
||||
getStateKey: jest.fn().mockReturnValue(groupCallId),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export const REMOTE_SFU_DESCRIPTION =
|
||||
"v=0\n" +
|
||||
"o=- 3242942315779688438 1678878001 IN IP4 0.0.0.0\n" +
|
||||
"s=-\n" +
|
||||
"t=0 0\n" +
|
||||
"a=fingerprint:sha-256 EA:30:B2:7F:49:B5:46:D6:40:72:BF:79:95:C1:65:08:6E:35:09:FB:90:89:DA:EF:6B:82:D1:38:8C:25:39:B2\n" +
|
||||
"a=group:BUNDLE 0 1 2\n" +
|
||||
"m=audio 9 UDP/TLS/RTP/SAVPF 111 9 0 8\n" +
|
||||
"c=IN IP4 0.0.0.0\n" +
|
||||
"a=setup:actpass\n" +
|
||||
"a=mid:0\n" +
|
||||
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
|
||||
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
|
||||
"a=rtcp-mux\n" +
|
||||
"a=rtcp-rsize\n" +
|
||||
"a=rtpmap:111 opus/48000/2\n" +
|
||||
"a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1\n" +
|
||||
"a=rtcp-fb:111 transport-cc \n" +
|
||||
"a=rtpmap:9 G722/8000\n" +
|
||||
"a=rtpmap:0 PCMU/8000\n" +
|
||||
"a=rtpmap:8 PCMA/8000\n" +
|
||||
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
|
||||
"a=ssrc:2963372119 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
|
||||
"a=ssrc:2963372119 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
|
||||
"a=ssrc:2963372119 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
|
||||
"a=ssrc:2963372119 label:4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
|
||||
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
|
||||
"a=sendrecv\n" +
|
||||
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 41385 typ host\n" +
|
||||
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 41385 typ host\n" +
|
||||
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 40026 typ host\n" +
|
||||
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 40026 typ host\n" +
|
||||
"a=end-of-candidates\n" +
|
||||
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 106 108 109 98 99 112 116\n" +
|
||||
"c=IN IP4 0.0.0.0\n" +
|
||||
"a=setup:actpass\n" +
|
||||
"a=mid:1\n" +
|
||||
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
|
||||
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
|
||||
"a=rtcp-mux\n" +
|
||||
"a=rtcp-rsize\n" +
|
||||
"a=rtpmap:96 VP8/90000\n" +
|
||||
"a=rtcp-fb:96 goog-remb \n" +
|
||||
"a=rtcp-fb:96 transport-cc \n" +
|
||||
"a=rtcp-fb:96 ccm fir\n" +
|
||||
"a=rtcp-fb:96 nack \n" +
|
||||
"a=rtcp-fb:96 nack pli\n" +
|
||||
"a=rtpmap:97 rtx/90000\n" +
|
||||
"a=fmtp:97 apt=96\n" +
|
||||
"a=rtpmap:102 H264/90000\n" +
|
||||
"a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\n" +
|
||||
"a=rtcp-fb:102 goog-remb \n" +
|
||||
"a=rtcp-fb:102 transport-cc \n" +
|
||||
"a=rtcp-fb:102 ccm fir\n" +
|
||||
"a=rtcp-fb:102 nack \n" +
|
||||
"a=rtcp-fb:102 nack pli\n" +
|
||||
"a=rtpmap:103 rtx/90000\n" +
|
||||
"a=fmtp:103 apt=102\n" +
|
||||
"a=rtpmap:104 H264/90000\n" +
|
||||
"a=fmtp:104 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\n" +
|
||||
"a=rtcp-fb:104 goog-remb \n" +
|
||||
"a=rtcp-fb:104 transport-cc \n" +
|
||||
"a=rtcp-fb:104 ccm fir\n" +
|
||||
"a=rtcp-fb:104 nack \n" +
|
||||
"a=rtcp-fb:104 nack pli\n" +
|
||||
"a=rtpmap:106 H264/90000\n" +
|
||||
"a=fmtp:106 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
|
||||
"a=rtcp-fb:106 goog-remb \n" +
|
||||
"a=rtcp-fb:106 transport-cc \n" +
|
||||
"a=rtcp-fb:106 ccm fir\n" +
|
||||
"a=rtcp-fb:106 nack \n" +
|
||||
"a=rtcp-fb:106 nack pli\n" +
|
||||
"a=rtpmap:108 H264/90000\n" +
|
||||
"a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\n" +
|
||||
"a=rtcp-fb:108 goog-remb \n" +
|
||||
"a=rtcp-fb:108 transport-cc \n" +
|
||||
"a=rtcp-fb:108 ccm fir\n" +
|
||||
"a=rtcp-fb:108 nack \n" +
|
||||
"a=rtcp-fb:108 nack pli\n" +
|
||||
"a=rtpmap:109 rtx/90000\n" +
|
||||
"a=fmtp:109 apt=108\n" +
|
||||
"a=rtpmap:98 VP9/90000\n" +
|
||||
"a=fmtp:98 profile-id=0\n" +
|
||||
"a=rtcp-fb:98 goog-remb \n" +
|
||||
"a=rtcp-fb:98 transport-cc \n" +
|
||||
"a=rtcp-fb:98 ccm fir\n" +
|
||||
"a=rtcp-fb:98 nack \n" +
|
||||
"a=rtcp-fb:98 nack pli\n" +
|
||||
"a=rtpmap:99 rtx/90000\n" +
|
||||
"a=fmtp:99 apt=98\n" +
|
||||
"a=rtpmap:112 H264/90000\n" +
|
||||
"a=fmtp:112 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f\n" +
|
||||
"a=rtcp-fb:112 goog-remb \n" +
|
||||
"a=rtcp-fb:112 transport-cc \n" +
|
||||
"a=rtcp-fb:112 ccm fir\n" +
|
||||
"a=rtcp-fb:112 nack \n" +
|
||||
"a=rtcp-fb:112 nack pli\n" +
|
||||
"a=rtpmap:116 ulpfec/90000\n" +
|
||||
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
|
||||
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
|
||||
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
|
||||
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
|
||||
"a=rid:f recv\n" +
|
||||
"a=rid:h recv\n" +
|
||||
"a=rid:q recv\n" +
|
||||
"a=simulcast:recv f;h;q\n" +
|
||||
"a=ssrc:1212931603 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
|
||||
"a=ssrc:1212931603 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
|
||||
"a=ssrc:1212931603 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
|
||||
"a=ssrc:1212931603 label:12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
|
||||
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
|
||||
"a=sendrecv\n" +
|
||||
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\n" +
|
||||
"c=IN IP4 0.0.0.0\n" +
|
||||
"a=setup:actpass\n" +
|
||||
"a=mid:2\n" +
|
||||
"a=sendrecv\n" +
|
||||
"a=sctp-port:5000\n" +
|
||||
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
|
||||
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs";
|
||||
|
||||
export const groupCallParticipantsFourOtherDevices = new Map([
|
||||
[
|
||||
new RoomMember("roomId0", "user1"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId0",
|
||||
{
|
||||
sessionId: "0",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"deviceId1",
|
||||
{
|
||||
sessionId: "1",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"deviceId2",
|
||||
{
|
||||
sessionId: "2",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
[
|
||||
new RoomMember("roomId0", "user2"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId3",
|
||||
{
|
||||
sessionId: "0",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"deviceId4",
|
||||
{
|
||||
sessionId: "1",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
export const groupCallParticipantsOneOtherDevice = new Map([
|
||||
[
|
||||
new RoomMember("roomId1", "thisMember"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId0",
|
||||
{
|
||||
sessionId: "0",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
[
|
||||
new RoomMember("roomId1", "opponentMember"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId1",
|
||||
{
|
||||
sessionId: "1",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,13 +46,7 @@ describe("NamespacedValue", () => {
|
||||
});
|
||||
|
||||
it("should not permit falsey values for both parts", () => {
|
||||
try {
|
||||
new UnstableValue(null!, null!);
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect((<Error>e).message).toBe("One of stable or unstable values must be supplied");
|
||||
}
|
||||
expect(() => new UnstableValue(null!, null!)).toThrow("One of stable or unstable values must be supplied");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,12 +66,6 @@ describe("UnstableValue", () => {
|
||||
});
|
||||
|
||||
it("should not permit falsey unstable values", () => {
|
||||
try {
|
||||
new UnstableValue("stable", null!);
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect((<Error>e).message).toBe("Unstable value must be supplied");
|
||||
}
|
||||
expect(() => new UnstableValue("stable", null!)).toThrow("Unstable value must be supplied");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { defer } from "../../src/utils";
|
||||
|
||||
describe("onResumedSync", () => {
|
||||
let batch: IndexedToDeviceBatch | null;
|
||||
@@ -58,7 +59,9 @@ describe("onResumedSync", () => {
|
||||
queue = new ToDeviceMessageQueue(mockClient);
|
||||
});
|
||||
|
||||
it("resends queue after connectivity restored", (done) => {
|
||||
it("resends queue after connectivity restored", async () => {
|
||||
const deferred = defer();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
|
||||
@@ -70,26 +73,32 @@ describe("onResumedSync", () => {
|
||||
onSendToDeviceSuccess = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
|
||||
expect(store.removeToDeviceBatch).toHaveBeenCalled();
|
||||
done();
|
||||
deferred.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if client sync still catching up", (done) => {
|
||||
it("does not resend queue if client sync still catching up", async () => {
|
||||
const deferred = defer();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
|
||||
|
||||
resumeSync(SyncState.Catchup, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
deferred.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if connectivity restored after queue stopped", (done) => {
|
||||
it("does not resend queue if connectivity restored after queue stopped", async () => {
|
||||
const deferred = defer();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.removeToDeviceBatch).not.toHaveBeenCalled();
|
||||
@@ -98,9 +107,10 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Syncing, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
done();
|
||||
deferred.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,17 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { M_AUTHENTICATION } from "../../src";
|
||||
import { AutoDiscovery } from "../../src/autodiscovery";
|
||||
import { OidcError } from "../../src/oidc/error";
|
||||
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
|
||||
|
||||
// keep to reset the fetch function after using MockHttpBackend
|
||||
// @ts-ignore private property
|
||||
const realAutoDiscoveryFetch: typeof global.fetch = AutoDiscovery.fetchFn;
|
||||
|
||||
describe("AutoDiscovery", function () {
|
||||
const getHttpBackend = (): MockHttpBackend => {
|
||||
@@ -26,6 +34,10 @@ describe("AutoDiscovery", function () {
|
||||
return httpBackend;
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
|
||||
});
|
||||
|
||||
it("should throw an error when no domain is specified", function () {
|
||||
getHttpBackend();
|
||||
return Promise.all([
|
||||
@@ -368,7 +380,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
);
|
||||
|
||||
it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + "m.homeserver", function () {
|
||||
it("should return SUCCESS when .well-known has a verifiably accurate base_url for m.homeserver", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
@@ -397,6 +409,10 @@ describe("AutoDiscovery", function () {
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -434,6 +450,54 @@ describe("AutoDiscovery", function () {
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return SUCCESS with authentication error when authentication config is invalid", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
// Note: we also expect this test to trim the trailing slash
|
||||
base_url: "https://chat.example.org/",
|
||||
},
|
||||
"m.authentication": {
|
||||
invalid: true,
|
||||
},
|
||||
});
|
||||
return Promise.all([
|
||||
httpBackend.flushAllExpected(),
|
||||
AutoDiscovery.findClientConfig("example.org").then((conf) => {
|
||||
const expected = {
|
||||
"m.homeserver": {
|
||||
state: "SUCCESS",
|
||||
error: null,
|
||||
base_url: "https://chat.example.org",
|
||||
},
|
||||
"m.identity_server": {
|
||||
state: "PROMPT",
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "FAIL_ERROR",
|
||||
error: OidcError.Misconfigured,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -625,7 +689,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
);
|
||||
|
||||
it("should return SUCCESS when the identity server configuration is " + "verifiably accurate", function () {
|
||||
it("should return SUCCESS when the identity server configuration is verifiably accurate", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
@@ -664,6 +728,10 @@ describe("AutoDiscovery", function () {
|
||||
error: null,
|
||||
base_url: "https://identity.example.org",
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -671,7 +739,7 @@ describe("AutoDiscovery", function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return SUCCESS and preserve non-standard keys from the " + ".well-known response", function () {
|
||||
it("should return SUCCESS and preserve non-standard keys from the .well-known response", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
@@ -716,6 +784,10 @@ describe("AutoDiscovery", function () {
|
||||
"org.example.custom.property": {
|
||||
cupcakes: "yes",
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -794,4 +866,75 @@ describe("AutoDiscovery", function () {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("m.authentication", () => {
|
||||
const homeserverName = "example.org";
|
||||
const homeserverUrl = "https://chat.example.org/";
|
||||
const issuer = "https://auth.org/";
|
||||
|
||||
beforeAll(() => {
|
||||
// make these tests independent from fetch mocking above
|
||||
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetBehavior();
|
||||
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["r0.0.1"] });
|
||||
|
||||
fetchMock.get("https://example.org/.well-known/matrix/client", {
|
||||
"m.homeserver": {
|
||||
// Note: we also expect this test to trim the trailing slash
|
||||
base_url: "https://chat.example.org/",
|
||||
},
|
||||
"m.authentication": {
|
||||
issuer,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return valid authentication configuration", async () => {
|
||||
const config = makeDelegatedAuthConfig(issuer);
|
||||
|
||||
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
const result = await AutoDiscovery.findClientConfig(homeserverName);
|
||||
|
||||
expect(result[M_AUTHENTICATION.stable!]).toEqual({
|
||||
state: AutoDiscovery.SUCCESS,
|
||||
...config,
|
||||
signingKeys: [],
|
||||
account: undefined,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set state to error for invalid authentication configuration", async () => {
|
||||
const config = makeDelegatedAuthConfig(issuer);
|
||||
// authorization_code is required
|
||||
config.metadata.grant_types_supported = ["openid"];
|
||||
|
||||
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
const result = await AutoDiscovery.findClientConfig(homeserverName);
|
||||
|
||||
expect(result[M_AUTHENTICATION.stable!]).toEqual({
|
||||
state: AutoDiscovery.FAIL_ERROR,
|
||||
error: OidcError.OpSupport,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+45
-58
@@ -304,20 +304,24 @@ describe("Crypto", function () {
|
||||
|
||||
describe("Key requests", function () {
|
||||
let aliceClient: MatrixClient;
|
||||
let secondAliceClient: MatrixClient;
|
||||
let bobClient: MatrixClient;
|
||||
let claraClient: MatrixClient;
|
||||
|
||||
beforeEach(async function () {
|
||||
aliceClient = new TestClient("@alice:example.com", "alicedevice").client;
|
||||
secondAliceClient = new TestClient("@alice:example.com", "secondAliceDevice").client;
|
||||
bobClient = new TestClient("@bob:example.com", "bobdevice").client;
|
||||
claraClient = new TestClient("@clara:example.com", "claradevice").client;
|
||||
await aliceClient.initCrypto();
|
||||
await secondAliceClient.initCrypto();
|
||||
await bobClient.initCrypto();
|
||||
await claraClient.initCrypto();
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
aliceClient.stopClient();
|
||||
secondAliceClient.stopClient();
|
||||
bobClient.stopClient();
|
||||
claraClient.stopClient();
|
||||
});
|
||||
@@ -377,12 +381,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
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
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -401,7 +400,7 @@ describe("Crypto", function () {
|
||||
// the first message can't be decrypted yet, but the second one
|
||||
// can
|
||||
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
|
||||
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
|
||||
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
|
||||
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
|
||||
await bobDecryptor.onRoomKeyEvent(ksEvent);
|
||||
await decryptEventsPromise;
|
||||
@@ -550,7 +549,7 @@ describe("Crypto", function () {
|
||||
aliceClient.crypto!.outgoingRoomKeyRequestManager.sendQueuedRequests();
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
expect(aliceSendToDevice).toBeCalledTimes(1);
|
||||
expect(aliceSendToDevice).toHaveBeenCalledTimes(1);
|
||||
const txnId = aliceSendToDevice.mock.calls[0][2];
|
||||
|
||||
// give the room key request manager time to update the state
|
||||
@@ -564,21 +563,21 @@ describe("Crypto", function () {
|
||||
// cancelAndResend will call sendToDevice twice:
|
||||
// the first call to sendToDevice will be the cancellation
|
||||
// the second call to sendToDevice will be the key request
|
||||
expect(aliceSendToDevice).toBeCalledTimes(3);
|
||||
expect(aliceSendToDevice).toHaveBeenCalledTimes(3);
|
||||
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
|
||||
});
|
||||
|
||||
it("should accept forwarded keys which it requested", async function () {
|
||||
it("should accept forwarded keys it requested from one of its own user's other devices", 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", {});
|
||||
const bobRoom = new Room(roomId, secondAliceClient, "@alice:example.com", {});
|
||||
aliceClient.store.storeRoom(aliceRoom);
|
||||
bobClient.store.storeRoom(bobRoom);
|
||||
secondAliceClient.store.storeRoom(bobRoom);
|
||||
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
|
||||
await bobClient.setRoomEncryption(roomId, encryptionCfg);
|
||||
await secondAliceClient.setRoomEncryption(roomId, encryptionCfg);
|
||||
const events = [
|
||||
new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
@@ -613,20 +612,16 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
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
|
||||
}
|
||||
await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
const device = new DeviceInfo(aliceClient.deviceId!);
|
||||
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
|
||||
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
|
||||
device.verified = DeviceInfo.DeviceVerification.VERIFIED;
|
||||
secondAliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
|
||||
secondAliceClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
|
||||
|
||||
const cryptoStore = bobClient.crypto!.cryptoStore;
|
||||
const cryptoStore = secondAliceClient.crypto!.cryptoStore;
|
||||
const eventContent = events[0].getWireContent();
|
||||
const senderKey = eventContent.sender_key;
|
||||
const sessionId = eventContent.session_id;
|
||||
@@ -642,7 +637,7 @@ describe("Crypto", function () {
|
||||
state: RoomKeyRequestState.Sent,
|
||||
});
|
||||
|
||||
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
|
||||
const bobDecryptor = secondAliceClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
|
||||
|
||||
const decryptEventsPromise = Promise.all(
|
||||
events.map((ev) => {
|
||||
@@ -651,7 +646,7 @@ describe("Crypto", function () {
|
||||
);
|
||||
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
|
||||
await bobDecryptor.onRoomKeyEvent(ksEvent);
|
||||
const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey(
|
||||
const key = await secondAliceClient.crypto!.olmDevice.getInboundGroupSessionKey(
|
||||
roomId,
|
||||
events[0].getWireContent().sender_key,
|
||||
events[0].getWireContent().session_id,
|
||||
@@ -720,12 +715,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
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
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -755,7 +745,7 @@ describe("Crypto", function () {
|
||||
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
|
||||
});
|
||||
|
||||
it("should accept forwarded keys from one of its own user's other devices", async function () {
|
||||
it("should not accept requested forwarded keys from other users", async function () {
|
||||
const encryptionCfg = {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
};
|
||||
@@ -800,40 +790,43 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
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
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
const device = new DeviceInfo(claraClient.deviceId!);
|
||||
const cryptoStore = bobClient.crypto!.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,
|
||||
};
|
||||
const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody);
|
||||
expect(outgoingReq).toBeDefined();
|
||||
await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, {
|
||||
state: RoomKeyRequestState.Sent,
|
||||
});
|
||||
|
||||
const device = new DeviceInfo(aliceClient.deviceId!);
|
||||
device.verified = DeviceInfo.DeviceVerification.VERIFIED;
|
||||
bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
|
||||
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@bob:example.com";
|
||||
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
|
||||
|
||||
const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM);
|
||||
|
||||
const decryptEventsPromise = Promise.all(
|
||||
events.map((ev) => {
|
||||
return awaitEvent(ev, "Event.decrypted");
|
||||
}),
|
||||
);
|
||||
const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
|
||||
ksEvent.event.sender = bobClient.getUserId()!;
|
||||
ksEvent.sender = new RoomMember(roomId, bobClient.getUserId()!);
|
||||
ksEvent.event.sender = aliceClient.getUserId()!;
|
||||
ksEvent.sender = new RoomMember(roomId, aliceClient.getUserId()!);
|
||||
await bobDecryptor.onRoomKeyEvent(ksEvent);
|
||||
const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey(
|
||||
roomId,
|
||||
events[0].getWireContent().sender_key,
|
||||
events[0].getWireContent().session_id,
|
||||
);
|
||||
expect(key).not.toBeNull();
|
||||
await decryptEventsPromise;
|
||||
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
|
||||
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
|
||||
expect(key).toBeNull();
|
||||
});
|
||||
|
||||
it("should not accept unexpected forwarded keys for a room it's in", async function () {
|
||||
@@ -884,12 +877,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
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
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -998,7 +986,6 @@ describe("Crypto", function () {
|
||||
jest.setTimeout(10000);
|
||||
const client = new TestClient("@a:example.com", "dev").client;
|
||||
await client.initCrypto();
|
||||
client.crypto!.getSecretStorageKey = jest.fn().mockResolvedValue(null);
|
||||
client.crypto!.isCrossSigningReady = async () => false;
|
||||
client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
|
||||
client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
|
||||
@@ -1026,7 +1013,7 @@ describe("Crypto", function () {
|
||||
|
||||
beforeEach(async () => {
|
||||
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
|
||||
ensureOlmSessionsForDevices.mockResolvedValue({});
|
||||
ensureOlmSessionsForDevices.mockResolvedValue(new Map());
|
||||
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
|
||||
encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => {
|
||||
result.plaintext = { type: 0, body: JSON.stringify(payload) };
|
||||
|
||||
@@ -102,9 +102,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () {
|
||||
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache });
|
||||
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
|
||||
expect(pubKey).toEqual(masterKeyPub);
|
||||
expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
|
||||
expect(getCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
|
||||
if (shouldCache) {
|
||||
expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(getCrossSigningKeyCache).toHaveBeenLastCalledWith(type, expect.any(String));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -115,10 +116,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () {
|
||||
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);
|
||||
expect(storeCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
|
||||
if (shouldCache) {
|
||||
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
|
||||
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(storeCrossSigningKeyCache).toHaveBeenLastCalledWith(type, testKey);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("DeviceList", function () {
|
||||
});
|
||||
});
|
||||
|
||||
it("should have an outdated devicelist on an invalidation while an " + "update is in progress", function () {
|
||||
it("should have an outdated devicelist on an invalidation while an update is in progress", async function () {
|
||||
const dl = createTestDeviceList();
|
||||
|
||||
dl.startTrackingDeviceList("@test1:sw1v.org");
|
||||
@@ -148,7 +148,8 @@ describe("DeviceList", function () {
|
||||
dl.invalidateUserDeviceList("@test1:sw1v.org");
|
||||
dl.refreshOutdatedDeviceLists();
|
||||
|
||||
dl.saveIfDirty()
|
||||
await dl
|
||||
.saveIfDirty()
|
||||
.then(() => {
|
||||
// the first request completes
|
||||
queryDefer1.resolve({
|
||||
@@ -159,12 +160,13 @@ describe("DeviceList", function () {
|
||||
});
|
||||
return prom1;
|
||||
})
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
// uh-oh; user restarts before second request completes. The new instance
|
||||
// should know we never got a complete device list.
|
||||
logger.log("Creating new devicelist to simulate app reload");
|
||||
downloadSpy.mockReset();
|
||||
const dl2 = createTestDeviceList();
|
||||
await dl2.load();
|
||||
const queryDefer3 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValue(queryDefer3.promise);
|
||||
|
||||
@@ -196,7 +198,7 @@ describe("DeviceList", function () {
|
||||
downloadSpy.mockReturnValueOnce(queryDefer2.promise);
|
||||
|
||||
const prom1 = dl.refreshOutdatedDeviceLists();
|
||||
expect(downloadSpy).toBeCalledTimes(2);
|
||||
expect(downloadSpy).toHaveBeenCalledTimes(2);
|
||||
expect(downloadSpy).toHaveBeenNthCalledWith(1, ["@test1:sw1v.org"], {});
|
||||
expect(downloadSpy).toHaveBeenNthCalledWith(2, ["@test2:sw1v.org"], {});
|
||||
queryDefer1.resolve(utils.deepCopy(signedDeviceList));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022 - 2023 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.
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import { mocked, MockedObject } from "jest-mock";
|
||||
|
||||
import type { DeviceInfoMap } from "../../../../src/crypto/DeviceList";
|
||||
import "../../../olm-loader";
|
||||
import type { OutboundGroupSession } from "@matrix-org/olm";
|
||||
import * as algorithms from "../../../../src/crypto/algorithms";
|
||||
@@ -33,6 +34,8 @@ import { ClientEvent, MatrixClient, RoomMember } from "../../../../src";
|
||||
import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo";
|
||||
import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning";
|
||||
import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm";
|
||||
import { recursiveMapToObject } from "../../../../src/utils";
|
||||
import { sleep } from "../../../../src/utils";
|
||||
|
||||
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
|
||||
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
|
||||
@@ -58,6 +61,12 @@ describe("MegolmDecryption", function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
mockCrypto = testUtils.mock(Crypto, "Crypto") as MockedObject<Crypto>;
|
||||
|
||||
// @ts-ignore assigning to readonly prop
|
||||
mockCrypto.backupManager = {
|
||||
backupGroupSession: () => {},
|
||||
};
|
||||
|
||||
mockBaseApis = {
|
||||
claimOneTimeKeys: jest.fn(),
|
||||
sendToDevice: jest.fn(),
|
||||
@@ -175,14 +184,22 @@ describe("MegolmDecryption", function () {
|
||||
const deviceInfo = {} as DeviceInfo;
|
||||
mockCrypto.getStoredDevice.mockReturnValue(deviceInfo);
|
||||
|
||||
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({
|
||||
"@alice:foo": {
|
||||
alidevice: {
|
||||
sessionId: "alisession",
|
||||
device: new DeviceInfo("alidevice"),
|
||||
},
|
||||
},
|
||||
});
|
||||
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
"@alice:foo",
|
||||
new Map([
|
||||
[
|
||||
"alidevice",
|
||||
{
|
||||
sessionId: "alisession",
|
||||
device: new DeviceInfo("alidevice"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const awaitEncryptForDevice = new Promise<void>((res, rej) => {
|
||||
mockOlmLib.encryptMessageForDevice.mockImplementation(() => {
|
||||
@@ -203,7 +220,7 @@ describe("MegolmDecryption", function () {
|
||||
.then(() => {
|
||||
// check that it called encryptMessageForDevice with
|
||||
// appropriate args.
|
||||
expect(mockOlmLib.encryptMessageForDevice).toBeCalledTimes(1);
|
||||
expect(mockOlmLib.encryptMessageForDevice).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = mockOlmLib.encryptMessageForDevice.mock.calls[0];
|
||||
const payload = call[6];
|
||||
@@ -314,10 +331,6 @@ describe("MegolmDecryption", function () {
|
||||
let olmDevice: OlmDevice;
|
||||
|
||||
beforeEach(async () => {
|
||||
// @ts-ignore assigning to readonly prop
|
||||
mockCrypto.backupManager = {
|
||||
backupGroupSession: () => {},
|
||||
};
|
||||
const cryptoStore = new MemoryCryptoStore();
|
||||
|
||||
olmDevice = new OlmDevice(cryptoStore);
|
||||
@@ -353,11 +366,7 @@ describe("MegolmDecryption", function () {
|
||||
} as unknown as DeviceInfo;
|
||||
|
||||
mockCrypto.downloadKeys.mockReturnValue(
|
||||
Promise.resolve({
|
||||
"@alice:home.server": {
|
||||
aliceDevice: aliceDeviceInfo,
|
||||
},
|
||||
}),
|
||||
Promise.resolve(new Map([["@alice:home.server", new Map([["aliceDevice", aliceDeviceInfo]])]])),
|
||||
);
|
||||
|
||||
mockCrypto.checkDeviceTrust.mockReturnValue({
|
||||
@@ -515,6 +524,87 @@ describe("MegolmDecryption", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareToEncrypt", () => {
|
||||
let megolm: MegolmEncryptionClass;
|
||||
let room: jest.Mocked<Room>;
|
||||
|
||||
const deviceMap: DeviceInfoMap = new Map([
|
||||
[
|
||||
"user-a",
|
||||
new Map([
|
||||
["device-a", new DeviceInfo("device-a")],
|
||||
["device-b", new DeviceInfo("device-b")],
|
||||
["device-c", new DeviceInfo("device-c")],
|
||||
]),
|
||||
],
|
||||
[
|
||||
"user-b",
|
||||
new Map([
|
||||
["device-d", new DeviceInfo("device-d")],
|
||||
["device-e", new DeviceInfo("device-e")],
|
||||
["device-f", new DeviceInfo("device-f")],
|
||||
]),
|
||||
],
|
||||
[
|
||||
"user-c",
|
||||
new Map([
|
||||
["device-g", new DeviceInfo("device-g")],
|
||||
["device-h", new DeviceInfo("device-h")],
|
||||
["device-i", new DeviceInfo("device-i")],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
room = testUtils.mock(Room, "Room") as jest.Mocked<Room>;
|
||||
room.getEncryptionTargetMembers.mockImplementation(async () => [
|
||||
new RoomMember(room.roomId, "@user:example.org"),
|
||||
]);
|
||||
room.getBlacklistUnverifiedDevices.mockReturnValue(false);
|
||||
|
||||
mockCrypto.downloadKeys.mockImplementation(async () => deviceMap);
|
||||
|
||||
mockCrypto.checkDeviceTrust.mockImplementation(() => new DeviceTrustLevel(true, true, true, true));
|
||||
|
||||
const olmDevice = new OlmDevice(new MemoryCryptoStore());
|
||||
megolm = new MegolmEncryptionClass({
|
||||
userId: "@user:id",
|
||||
deviceId: "12345",
|
||||
crypto: mockCrypto,
|
||||
olmDevice,
|
||||
baseApis: mockBaseApis,
|
||||
roomId: room.roomId,
|
||||
config: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
rotation_period_ms: 9_999_999,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("checks each device", async () => {
|
||||
megolm.prepareToEncrypt(room);
|
||||
//@ts-ignore private member access, gross
|
||||
await megolm.encryptionPreparation?.promise;
|
||||
|
||||
for (const [userId, devices] of deviceMap) {
|
||||
for (const deviceId of devices.keys()) {
|
||||
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is cancellable", async () => {
|
||||
const stop = megolm.prepareToEncrypt(room);
|
||||
|
||||
const before = mockCrypto.checkDeviceTrust.mock.calls.length;
|
||||
stop();
|
||||
|
||||
// Ensure that no more devices were checked after cancellation.
|
||||
await sleep(10);
|
||||
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledTimes(before);
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies devices that have been blocked", async function () {
|
||||
const aliceClient = new TestClient("@alice:example.com", "alicedevice").client;
|
||||
const bobClient1 = new TestClient("@bob:example.com", "bobdevice1").client;
|
||||
@@ -582,20 +672,20 @@ describe("MegolmDecryption", function () {
|
||||
expect(aliceClient.sendToDevice).toHaveBeenCalled();
|
||||
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
|
||||
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
|
||||
delete contentMap["@bob:example.com"].bobdevice1.session_id;
|
||||
delete contentMap["@bob:example.com"].bobdevice1["org.matrix.msgid"];
|
||||
delete contentMap["@bob:example.com"].bobdevice2.session_id;
|
||||
delete contentMap["@bob:example.com"].bobdevice2["org.matrix.msgid"];
|
||||
expect(contentMap).toStrictEqual({
|
||||
"@bob:example.com": {
|
||||
bobdevice1: {
|
||||
delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["session_id"];
|
||||
delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["org.matrix.msgid"];
|
||||
delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["session_id"];
|
||||
delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["org.matrix.msgid"];
|
||||
expect(recursiveMapToObject(contentMap)).toStrictEqual({
|
||||
["@bob:example.com"]: {
|
||||
["bobdevice1"]: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: roomId,
|
||||
code: "m.unverified",
|
||||
reason: "The sender has disabled encrypting to unverified devices.",
|
||||
sender_key: aliceDevice.deviceCurve25519Key,
|
||||
},
|
||||
bobdevice2: {
|
||||
["bobdevice2"]: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: roomId,
|
||||
code: "m.blacklisted",
|
||||
@@ -763,10 +853,10 @@ describe("MegolmDecryption", function () {
|
||||
expect(aliceClient.sendToDevice).toHaveBeenCalled();
|
||||
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
|
||||
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
|
||||
delete contentMap["@bob:example.com"]["bobdevice"]["org.matrix.msgid"];
|
||||
expect(contentMap).toStrictEqual({
|
||||
"@bob:example.com": {
|
||||
bobdevice: {
|
||||
delete contentMap.get("@bob:example.com")?.get("bobdevice")?.["org.matrix.msgid"];
|
||||
expect(recursiveMapToObject(contentMap)).toStrictEqual({
|
||||
["@bob:example.com"]: {
|
||||
["bobdevice"]: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
code: "m.no_olm",
|
||||
reason: "Unable to establish a secure channel.",
|
||||
|
||||
@@ -146,18 +146,21 @@ describe("OlmDevice", function () {
|
||||
});
|
||||
},
|
||||
} as unknown as MockedObject<MatrixClient>;
|
||||
const devicesByUser = {
|
||||
"@bob:example.com": [
|
||||
DeviceInfo.fromStorage(
|
||||
{
|
||||
keys: {
|
||||
"curve25519:ABCDEFG": "akey",
|
||||
const devicesByUser = new Map([
|
||||
[
|
||||
"@bob:example.com",
|
||||
[
|
||||
DeviceInfo.fromStorage(
|
||||
{
|
||||
keys: {
|
||||
"curve25519:ABCDEFG": "akey",
|
||||
},
|
||||
},
|
||||
},
|
||||
"ABCDEFG",
|
||||
),
|
||||
"ABCDEFG",
|
||||
),
|
||||
],
|
||||
],
|
||||
};
|
||||
]);
|
||||
|
||||
// start two tasks that try to ensure that there's an olm session
|
||||
const promises = Promise.all([
|
||||
@@ -218,12 +221,8 @@ describe("OlmDevice", function () {
|
||||
// There's no required ordering of devices per user, so here we
|
||||
// create two different orderings so that each task reserves a
|
||||
// device the other task needs before continuing.
|
||||
const devicesByUserAB = {
|
||||
"@bob:example.com": [deviceBobA, deviceBobB],
|
||||
};
|
||||
const devicesByUserBA = {
|
||||
"@bob:example.com": [deviceBobB, deviceBobA],
|
||||
};
|
||||
const devicesByUserAB = new Map([["@bob:example.com", [deviceBobA, deviceBobB]]]);
|
||||
const devicesByUserBA = new Map([["@bob:example.com", [deviceBobB, deviceBobA]]]);
|
||||
|
||||
const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserAB));
|
||||
|
||||
|
||||
@@ -456,6 +456,7 @@ describe("MegolmBackup", function () {
|
||||
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
|
||||
++numCalls;
|
||||
expect(numCalls).toBeLessThanOrEqual(2);
|
||||
/* eslint-disable jest/no-conditional-expect */
|
||||
if (numCalls === 1) {
|
||||
expect(method).toBe("POST");
|
||||
expect(path).toBe("/room_keys/version");
|
||||
@@ -482,6 +483,7 @@ describe("MegolmBackup", function () {
|
||||
reject(new Error("authedRequest called too many times"));
|
||||
return Promise.resolve({});
|
||||
}
|
||||
/* eslint-enable jest/no-conditional-expect */
|
||||
};
|
||||
}),
|
||||
client.createKeyBackupVersion({
|
||||
|
||||
@@ -24,10 +24,11 @@ import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { MatrixError } from "../../../src/http-api";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
|
||||
import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto";
|
||||
import { CryptoEvent } from "../../../src/crypto";
|
||||
import { IDevice } from "../../../src/crypto/deviceinfo";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
|
||||
|
||||
const PUSH_RULES_RESPONSE: Response = {
|
||||
method: "GET",
|
||||
@@ -146,7 +147,7 @@ describe("Cross Signing", function () {
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
|
||||
const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
await func({});
|
||||
};
|
||||
|
||||
@@ -1148,6 +1149,6 @@ describe("userHasCrossSigningKeys", function () {
|
||||
|
||||
it("throws an error if crypto is disabled", () => {
|
||||
aliceClient["cryptoBackend"] = undefined;
|
||||
expect(() => aliceClient.userHasCrossSigningKeys()).toThrowError("encryption disabled");
|
||||
expect(() => aliceClient.userHasCrossSigningKeys()).toThrow("encryption disabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright 2023 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 { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { DeviceVerification } from "../../../src";
|
||||
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
|
||||
|
||||
describe("device-converter", () => {
|
||||
const userId = "@alice:example.com";
|
||||
const deviceId = "xcvf";
|
||||
|
||||
// All parameters for DeviceInfo initialization
|
||||
const keys = {
|
||||
[`ed25519:${deviceId}`]: "key1",
|
||||
[`curve25519:${deviceId}`]: "key2",
|
||||
};
|
||||
const algorithms = ["algo1", "algo2"];
|
||||
const verified = DeviceVerification.Verified;
|
||||
const signatures = { [userId]: { [deviceId]: "sign1" } };
|
||||
const displayName = "display name";
|
||||
const unsigned = {
|
||||
device_display_name: displayName,
|
||||
};
|
||||
|
||||
describe("deviceInfoToDevice", () => {
|
||||
it("should convert a DeviceInfo to a Device", () => {
|
||||
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
|
||||
const device = deviceInfoToDevice(deviceInfo, userId);
|
||||
|
||||
expect(device.deviceId).toBe(deviceId);
|
||||
expect(device.userId).toBe(userId);
|
||||
expect(device.verified).toBe(verified);
|
||||
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
|
||||
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
|
||||
expect(device.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it("should add empty signatures", () => {
|
||||
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
|
||||
const device = deviceInfoToDevice(deviceInfo, userId);
|
||||
|
||||
expect(device.signatures.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
import "../../olm-loader";
|
||||
import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { IObject } from "../../../src/crypto/olmlib";
|
||||
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/crypto/SecretStorage";
|
||||
import { MatrixEvent } from "../../../src/models/event";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { makeTestClients } from "./verification/util";
|
||||
@@ -25,10 +24,10 @@ import { encryptAES } from "../../../src/crypto/aes";
|
||||
import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client";
|
||||
import { ISecretStorageKeyInfo } from "../../../src/crypto/api";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { ISignatures } from "../../../src/@types/signed";
|
||||
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
|
||||
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string; deviceId: string },
|
||||
@@ -45,7 +44,7 @@ async function makeTestClient(
|
||||
await client.initCrypto();
|
||||
|
||||
// No need to download keys for these tests
|
||||
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue({});
|
||||
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue(new Map());
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -149,22 +148,14 @@ describe("Secrets", function () {
|
||||
it("should throw if given a key that doesn't exist", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar", ["this secret does not exist"]);
|
||||
// should be able to use expect(...).toThrow() but mocha still fails
|
||||
// the test even when it throws for reasons I have no inclination to debug
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should refuse to encrypt with zero keys", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar", []);
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
@@ -215,10 +206,7 @@ describe("Secrets", function () {
|
||||
it("should refuse to encrypt if no keys given and no default key", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar");
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
@@ -274,7 +262,7 @@ describe("Secrets", function () {
|
||||
Object.values(otks)[0],
|
||||
);
|
||||
|
||||
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
|
||||
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
|
||||
osborne2.client.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
|
||||
|
||||
const request = await secretStorage.request("foo", ["VAX"]);
|
||||
@@ -541,7 +529,9 @@ describe("Secrets", function () {
|
||||
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<ISecretStorageKeyInfo>();
|
||||
const keyInfo = alice
|
||||
.getAccountData("m.secret_storage.key.key_id")!
|
||||
.getContent<SecretStorageKeyDescription>();
|
||||
expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2");
|
||||
expect(keyInfo.passphrase).toEqual({
|
||||
algorithm: "m.pbkdf2",
|
||||
|
||||
@@ -121,12 +121,12 @@ describe("SAS verification", function () {
|
||||
|
||||
alice.client.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES);
|
||||
alice.client.downloadKeys = () => {
|
||||
return Promise.resolve({});
|
||||
return Promise.resolve(new Map());
|
||||
};
|
||||
|
||||
bob.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", ALICE_DEVICES);
|
||||
bob.client.downloadKeys = () => {
|
||||
return Promise.resolve({});
|
||||
return Promise.resolve(new Map());
|
||||
};
|
||||
|
||||
aliceSasEvent = null;
|
||||
@@ -144,7 +144,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(aliceSasEvent.sas);
|
||||
e.confirm();
|
||||
aliceSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
aliceSasEvent.mismatch();
|
||||
}
|
||||
@@ -169,13 +169,14 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(bobSasEvent.sas);
|
||||
e.confirm();
|
||||
bobSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
bobSasEvent.mismatch();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([alice.stop(), bob.stop()]);
|
||||
|
||||
@@ -186,10 +187,14 @@ describe("SAS verification", function () {
|
||||
let macMethod;
|
||||
let keyAgreement;
|
||||
const origSendToDevice = bob.client.sendToDevice.bind(bob.client);
|
||||
bob.client.sendToDevice = function (type, map) {
|
||||
bob.client.sendToDevice = async (type, map) => {
|
||||
if (type === "m.key.verification.accept") {
|
||||
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
|
||||
keyAgreement = map[alice.client.getUserId()!][alice.client.deviceId!].key_agreement_protocol;
|
||||
macMethod = map
|
||||
.get(alice.client.getUserId()!)
|
||||
?.get(alice.client.deviceId!)?.message_authentication_code;
|
||||
keyAgreement = map
|
||||
.get(alice.client.getUserId()!)
|
||||
?.get(alice.client.deviceId!)?.key_agreement_protocol;
|
||||
}
|
||||
return origSendToDevice(type, map);
|
||||
};
|
||||
@@ -215,7 +220,7 @@ describe("SAS verification", function () {
|
||||
]);
|
||||
|
||||
// make sure that it uses the preferred method
|
||||
expect(macMethod).toBe("org.matrix.msc3783.hkdf-hmac-sha256");
|
||||
expect(macMethod).toBe("hkdf-hmac-sha256.v2");
|
||||
expect(keyAgreement).toBe("curve25519-hkdf-sha256");
|
||||
|
||||
// make sure Alice and Bob verified each other
|
||||
@@ -237,7 +242,7 @@ describe("SAS verification", function () {
|
||||
// has, since it is the same object. If this does not
|
||||
// happen, the verification will fail due to a hash
|
||||
// commitment mismatch.
|
||||
map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = [
|
||||
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
|
||||
"hkdf-hmac-sha256",
|
||||
];
|
||||
}
|
||||
@@ -246,7 +251,9 @@ describe("SAS verification", function () {
|
||||
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
|
||||
bob.client.sendToDevice = (type, map) => {
|
||||
if (type === "m.key.verification.accept") {
|
||||
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
|
||||
macMethod = map
|
||||
.get(alice.client.getUserId()!)!
|
||||
.get(alice.client.deviceId!)!.message_authentication_code;
|
||||
}
|
||||
return bobOrigSendToDevice(type, map);
|
||||
};
|
||||
@@ -291,14 +298,18 @@ describe("SAS verification", function () {
|
||||
// has, since it is the same object. If this does not
|
||||
// happen, the verification will fail due to a hash
|
||||
// commitment mismatch.
|
||||
map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = ["hmac-sha256"];
|
||||
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
|
||||
"hmac-sha256",
|
||||
];
|
||||
}
|
||||
return aliceOrigSendToDevice(type, map);
|
||||
};
|
||||
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
|
||||
bob.client.sendToDevice = (type, map) => {
|
||||
if (type === "m.key.verification.accept") {
|
||||
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
|
||||
macMethod = map
|
||||
.get(alice.client.getUserId()!)!
|
||||
.get(alice.client.deviceId!)!.message_authentication_code;
|
||||
}
|
||||
return bobOrigSendToDevice(type, map);
|
||||
};
|
||||
@@ -363,6 +374,12 @@ describe("SAS verification", function () {
|
||||
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
|
||||
|
||||
const bobDeviceVerificationStatus = (await alice.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
|
||||
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
|
||||
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
|
||||
|
||||
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
|
||||
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(aliceTrust.isTofu()).toBeTruthy();
|
||||
@@ -370,6 +387,17 @@ describe("SAS verification", function () {
|
||||
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
|
||||
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
|
||||
|
||||
const aliceDeviceVerificationStatus = (await bob.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
|
||||
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
|
||||
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
|
||||
|
||||
const unknownDeviceVerificationStatus = await bob.client
|
||||
.getCrypto()!
|
||||
.getDeviceVerificationStatus("@alice:example.com", "xyz");
|
||||
expect(unknownDeviceVerificationStatus).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -454,7 +482,7 @@ describe("SAS verification", function () {
|
||||
);
|
||||
};
|
||||
alice.client.downloadKeys = () => {
|
||||
return Promise.resolve({});
|
||||
return Promise.resolve(new Map());
|
||||
};
|
||||
|
||||
bob.client.crypto!.setDeviceVerification = jest.fn();
|
||||
@@ -472,7 +500,7 @@ describe("SAS verification", function () {
|
||||
return "bob+base64+ed25519+key";
|
||||
};
|
||||
bob.client.downloadKeys = () => {
|
||||
return Promise.resolve({});
|
||||
return Promise.resolve(new Map());
|
||||
};
|
||||
|
||||
aliceSasEvent = null;
|
||||
@@ -491,7 +519,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(aliceSasEvent.sas);
|
||||
e.confirm();
|
||||
aliceSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
aliceSasEvent.mismatch();
|
||||
}
|
||||
@@ -515,7 +543,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(bobSasEvent.sas);
|
||||
e.confirm();
|
||||
bobSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
bobSasEvent.mismatch();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "../../../../src/models/event";
|
||||
import { IRoomTimelineData } from "../../../../src/models/event-timeline-set";
|
||||
import { Room, RoomEvent } from "../../../../src/models/room";
|
||||
import { logger } from "../../../../src/logger";
|
||||
import { MatrixClient, ClientEvent, ICreateClientOpts } from "../../../../src/client";
|
||||
import { MatrixClient, ClientEvent, ICreateClientOpts, SendToDeviceContentMap } from "../../../../src/client";
|
||||
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
@@ -36,16 +36,16 @@ export async function makeTestClients(
|
||||
const clientMap: Record<string, Record<string, MatrixClient>> = {};
|
||||
const makeSendToDevice =
|
||||
(matrixClient: MatrixClient): MatrixClient["sendToDevice"] =>
|
||||
async (type, map) => {
|
||||
async (type: string, contentMap: SendToDeviceContentMap) => {
|
||||
// logger.log(this.getUserId(), "sends", type, map);
|
||||
for (const [userId, devMap] of Object.entries(map)) {
|
||||
for (const [userId, deviceMessages] of contentMap) {
|
||||
if (userId in clientMap) {
|
||||
for (const [deviceId, msg] of Object.entries(devMap)) {
|
||||
for (const [deviceId, message] of deviceMessages) {
|
||||
if (deviceId in clientMap[userId]) {
|
||||
const event = new MatrixEvent({
|
||||
sender: matrixClient.getUserId()!,
|
||||
type: type,
|
||||
content: msg,
|
||||
content: message,
|
||||
});
|
||||
const client = clientMap[userId][deviceId];
|
||||
const decryptionPromise = event.isEncrypted()
|
||||
|
||||
@@ -25,6 +25,7 @@ import { IContent, MatrixEvent } from "../../../../src/models/event";
|
||||
import { MatrixClient } from "../../../../src/client";
|
||||
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
|
||||
import { VerificationBase } from "../../../../src/crypto/verification/Base";
|
||||
import { MapWithDefault } from "../../../../src/utils";
|
||||
|
||||
type MockClient = MatrixClient & {
|
||||
popEvents: () => MatrixEvent[];
|
||||
@@ -33,7 +34,9 @@ type MockClient = MatrixClient & {
|
||||
function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
let counter = 1;
|
||||
let events: MatrixEvent[] = [];
|
||||
const deviceEvents: Record<string, Record<string, MatrixEvent[]>> = {};
|
||||
const deviceEvents: MapWithDefault<string, MapWithDefault<string, MatrixEvent[]>> = new MapWithDefault(
|
||||
() => new MapWithDefault(() => []),
|
||||
);
|
||||
return {
|
||||
getUserId() {
|
||||
return userId;
|
||||
@@ -58,15 +61,11 @@ function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
return Promise.resolve({ event_id: eventId });
|
||||
},
|
||||
|
||||
sendToDevice(type: string, msgMap: Record<string, Record<string, IContent>>) {
|
||||
for (const userId of Object.keys(msgMap)) {
|
||||
const deviceMap = msgMap[userId];
|
||||
for (const deviceId of Object.keys(deviceMap)) {
|
||||
const content = deviceMap[deviceId];
|
||||
sendToDevice(type: string, msgMap: Map<string, Map<string, IContent>>) {
|
||||
for (const [userId, deviceMessages] of msgMap) {
|
||||
for (const [deviceId, content] of deviceMessages) {
|
||||
const event = new MatrixEvent({ content, type });
|
||||
deviceEvents[userId] = deviceEvents[userId] || {};
|
||||
deviceEvents[userId][deviceId] = deviceEvents[userId][deviceId] || [];
|
||||
deviceEvents[userId][deviceId].push(event);
|
||||
deviceEvents.getOrCreate(userId).getOrCreate(deviceId).push(event);
|
||||
}
|
||||
}
|
||||
return Promise.resolve({});
|
||||
@@ -79,14 +78,9 @@ function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
return e;
|
||||
},
|
||||
|
||||
// @ts-ignore special testing fn
|
||||
popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] {
|
||||
const forDevice = deviceEvents[userId];
|
||||
const events = forDevice && forDevice[deviceId];
|
||||
const result = events || [];
|
||||
if (events) {
|
||||
delete forDevice[deviceId];
|
||||
}
|
||||
const result = deviceEvents.get(userId)?.get(deviceId) || [];
|
||||
deviceEvents?.get(userId)?.delete(deviceId);
|
||||
return result;
|
||||
},
|
||||
} as unknown as MockClient;
|
||||
|
||||
@@ -204,9 +204,14 @@ describe("RoomWidgetClient", () => {
|
||||
});
|
||||
|
||||
describe("to-device messages", () => {
|
||||
const unencryptedContentMap = {
|
||||
"@alice:example.org": { "*": { hello: "alice!" } },
|
||||
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
|
||||
const unencryptedContentMap = new Map([
|
||||
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
|
||||
["@bob:example.org", new Map([["bobDesktop", { hello: "bob!" }]])],
|
||||
]);
|
||||
|
||||
const expectedRequestData = {
|
||||
["@alice:example.org"]: { ["*"]: { hello: "alice!" } },
|
||||
["@bob:example.org"]: { ["bobDesktop"]: { hello: "bob!" } },
|
||||
};
|
||||
|
||||
it("sends unencrypted (sendToDevice)", async () => {
|
||||
@@ -214,7 +219,7 @@ describe("RoomWidgetClient", () => {
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
await client.sendToDevice("org.example.foo", unencryptedContentMap);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends unencrypted (queueToDevice)", async () => {
|
||||
@@ -229,7 +234,7 @@ describe("RoomWidgetClient", () => {
|
||||
],
|
||||
};
|
||||
await client.queueToDevice(batch);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2022 - 2023 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.
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import {
|
||||
DuplicateStrategy,
|
||||
@@ -24,13 +26,10 @@ import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from "../../src";
|
||||
import { FeatureSupport, Thread } from "../../src/models/thread";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { ReEmitter } from "../../src/ReEmitter";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
describe("EventTimelineSet", () => {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -82,6 +81,7 @@ describe("EventTimelineSet", () => {
|
||||
beforeEach(() => {
|
||||
client = utils.mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = utils.mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
room = new Room(roomId, client, userA);
|
||||
eventTimelineSet = new EventTimelineSet(room);
|
||||
eventTimeline = new EventTimeline(eventTimelineSet);
|
||||
@@ -144,13 +144,6 @@ describe("EventTimelineSet", () => {
|
||||
});
|
||||
|
||||
describe("addEventToTimeline", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
it("Adds event to timeline", () => {
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
@@ -170,6 +163,42 @@ describe("EventTimelineSet", () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should aggregate relations which belong to unknown timeline without adding them to any timeline", () => {
|
||||
// If threads are disabled all events go into the main timeline
|
||||
mocked(client.supportsThreads).mockReturnValue(true);
|
||||
const reactionEvent = utils.mkReaction(messageEvent, client, client.getSafeUserId(), roomId);
|
||||
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
eventTimelineSet.addEventToTimeline(reactionEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents()).toHaveLength(1);
|
||||
const [event] = liveTimeline.getEvents();
|
||||
const reactions = eventTimelineSet.relations!.getChildEventsForEvent(
|
||||
event.getId()!,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
)!;
|
||||
const relations = reactions.getRelations();
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0].getId()).toBe(reactionEvent.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEventToTimeline (thread timeline)", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
it("should not add an event to a timeline that does not belong to the timelineSet", () => {
|
||||
const eventTimelineSet2 = new EventTimelineSet(room);
|
||||
const liveTimeline2 = eventTimelineSet2.getLiveTimeline();
|
||||
@@ -179,7 +208,7 @@ describe("EventTimelineSet", () => {
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline2, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
}).toThrowError();
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("should not add a threaded reply to the main room timeline", () => {
|
||||
@@ -199,94 +228,19 @@ describe("EventTimelineSet", () => {
|
||||
const liveTimeline = eventTimelineSetForThread.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
eventTimelineSetForThread.addEventToTimeline(messageEvent, liveTimeline, {
|
||||
const normalMessage = utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "Hello!",
|
||||
event: true,
|
||||
});
|
||||
|
||||
eventTimelineSetForThread.addEventToTimeline(normalMessage, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it("should allow edits to be added to thread timeline", async () => {
|
||||
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
|
||||
const sender = "@alice:matrix.org";
|
||||
|
||||
const root = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
body: "Thread root",
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
room.addLiveEvents([root]);
|
||||
|
||||
const threadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "Thread reply",
|
||||
"m.relates_to": {
|
||||
event_id: root.getId()!,
|
||||
rel_type: RelationType.Thread,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
root.setUnsigned({
|
||||
"m.relations": {
|
||||
[RelationType.Thread]: {
|
||||
count: 1,
|
||||
latest_event: {
|
||||
content: threadReply.getContent(),
|
||||
origin_server_ts: 5,
|
||||
room_id: room.roomId,
|
||||
sender,
|
||||
type: EventType.RoomMessage,
|
||||
event_id: threadReply.getId()!,
|
||||
user_id: sender,
|
||||
age: 1,
|
||||
},
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editToThreadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": " * edit",
|
||||
"m.new_content": {
|
||||
"body": "edit",
|
||||
"msgtype": "m.text",
|
||||
"org.matrix.msc1767.text": "edit",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: threadReply.getId()!,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
|
||||
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(client, "relations").mockResolvedValue({
|
||||
events: [],
|
||||
});
|
||||
|
||||
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
|
||||
thread.once(RoomEvent.TimelineReset, () => {
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
expect(lastEvent.getContent().body).toBe(" * edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-room timeline", () => {
|
||||
it("Adds event to timeline", () => {
|
||||
const nonRoomEventTimelineSet = new EventTimelineSet(
|
||||
@@ -393,13 +347,13 @@ describe("EventTimelineSet", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
|
||||
(client.supportsThreads 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();
|
||||
expect(() => eventTimelineSet.canContain(messageEvent)).toThrow();
|
||||
});
|
||||
|
||||
it("should return false if timeline set is for thread but event is not threaded", () => {
|
||||
@@ -420,7 +374,9 @@ describe("EventTimelineSet", () => {
|
||||
});
|
||||
|
||||
it("should return true if the timeline set is not for a thread and the event is a thread root", () => {
|
||||
const thread = new Thread(messageEvent.getId()!, messageEvent, { room, client });
|
||||
const eventTimelineSet = new EventTimelineSet(room, {}, client);
|
||||
messageEvent.setThread(thread);
|
||||
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -150,26 +150,6 @@ describe("PollResponseEvent", () => {
|
||||
expect(response.spoiled).toBe(true);
|
||||
});
|
||||
|
||||
it("should spoil the vote when answers are empty", () => {
|
||||
const input: IPartialEvent<PollResponseEventContent> = {
|
||||
type: M_POLL_RESPONSE.name,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: REFERENCE_RELATION.name,
|
||||
event_id: "$poll",
|
||||
},
|
||||
[M_POLL_RESPONSE.name]: {
|
||||
answers: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const response = new PollResponseEvent(input);
|
||||
expect(response.spoiled).toBe(true);
|
||||
|
||||
response.validateAgainst(SAMPLE_POLL);
|
||||
expect(response.spoiled).toBe(true);
|
||||
});
|
||||
|
||||
it("should spoil the vote when answers are not strings", () => {
|
||||
const input: IPartialEvent<PollResponseEventContent> = {
|
||||
type: M_POLL_RESPONSE.name,
|
||||
|
||||
@@ -14,10 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { FetchHttpApi } from "../../../src/http-api/fetch";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, QueryDict } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
@@ -235,4 +239,86 @@ describe("FetchHttpApi", () => {
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl()", () => {
|
||||
const localBaseUrl = "http://baseurl";
|
||||
const baseUrlWithTrailingSlash = "http://baseurl/";
|
||||
const makeApi = (thisBaseUrl = baseUrl): FetchHttpApi<any> => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn });
|
||||
};
|
||||
|
||||
type TestParams = {
|
||||
path: string;
|
||||
queryParams?: QueryDict;
|
||||
prefix?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
type TestCase = [TestParams, string];
|
||||
const queryParams: QueryDict = {
|
||||
test1: 99,
|
||||
test2: ["a", "b"],
|
||||
};
|
||||
const testPrefix = "/just/testing";
|
||||
const testUrl = "http://justtesting.com";
|
||||
const testUrlWithTrailingSlash = "http://justtesting.com/";
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
[{ path: "/terms" }, `${localBaseUrl}${prefix}/terms`],
|
||||
[{ path: "/terms", queryParams }, `${localBaseUrl}${prefix}/terms?test1=99&test2=a&test2=b`],
|
||||
[{ path: "/terms", prefix: testPrefix }, `${localBaseUrl}${testPrefix}/terms`],
|
||||
[{ path: "/terms", baseUrl: testUrl }, `${testUrl}${prefix}/terms`],
|
||||
[{ path: "/terms", baseUrl: testUrlWithTrailingSlash }, `${testUrl}${prefix}/terms`],
|
||||
[
|
||||
{ path: "/terms", queryParams, prefix: testPrefix, baseUrl: testUrl },
|
||||
`${testUrl}${testPrefix}/terms?test1=99&test2=a&test2=b`,
|
||||
],
|
||||
];
|
||||
const runTests = (fetchBaseUrl: string) => {
|
||||
it.each<TestCase>(testCases)(
|
||||
"creates url with params %s => %s",
|
||||
({ path, queryParams, prefix, baseUrl }, expected) => {
|
||||
const api = makeApi(fetchBaseUrl);
|
||||
|
||||
const result = api.getUrl(path, queryParams, prefix, baseUrl);
|
||||
// we only check the stringified URL, to avoid having the test depend on the internals of URL.
|
||||
expect(result.toString()).toEqual(expected);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("when fetch.opts.baseUrl does not have a trailing slash", () => {
|
||||
runTests(localBaseUrl);
|
||||
});
|
||||
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
|
||||
runTests(baseUrlWithTrailingSlash);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log query parameters", async () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const prom = api.requestOtherUrl(Method.Get, "https://server:8448/some/path?query=param#fragment");
|
||||
jest.advanceTimersByTime(1234);
|
||||
deferred.resolve({ ok: true, status: 200, text: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(logger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(logger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mocked(logger.debug).mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
|
||||
]
|
||||
`);
|
||||
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import DOMException from "domexception";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { ClientPrefix, MatrixHttpApi, Method, UploadResponse } from "../../../src";
|
||||
@@ -33,8 +32,6 @@ describe("MatrixHttpApi", () => {
|
||||
|
||||
const DONE = 0;
|
||||
|
||||
global.DOMException = DOMException;
|
||||
|
||||
beforeEach(() => {
|
||||
xhr = {
|
||||
upload: {} as XMLHttpRequestUpload,
|
||||
|
||||
@@ -78,11 +78,11 @@ describe("InteractiveAuth", () => {
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
expect(doRequest).toHaveBeenCalledTimes(1);
|
||||
expect(stateUpdated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle auth errcode presence ", async () => {
|
||||
it("should handle auth errcode presence", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
@@ -94,7 +94,6 @@ describe("InteractiveAuth", () => {
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Password] }],
|
||||
errcode: "MockError0",
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
@@ -128,8 +127,8 @@ describe("InteractiveAuth", () => {
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
expect(doRequest).toHaveBeenCalledTimes(1);
|
||||
expect(stateUpdated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle set emailSid for email flow", async () => {
|
||||
@@ -180,9 +179,9 @@ describe("InteractiveAuth", () => {
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
expect(requestEmailToken).toBeCalledTimes(0);
|
||||
expect(doRequest).toHaveBeenCalledTimes(1);
|
||||
expect(stateUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(requestEmailToken).toHaveBeenCalledTimes(0);
|
||||
expect(ia.getEmailSid()).toBe("myEmailSid");
|
||||
});
|
||||
|
||||
@@ -244,8 +243,8 @@ describe("InteractiveAuth", () => {
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(2);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
expect(doRequest).toHaveBeenCalledTimes(2);
|
||||
expect(stateUpdated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should make a request if authdata is null", async () => {
|
||||
@@ -306,8 +305,8 @@ describe("InteractiveAuth", () => {
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(2);
|
||||
expect(stateUpdated).toBeCalledTimes(1);
|
||||
expect(doRequest).toHaveBeenCalledTimes(2);
|
||||
expect(stateUpdated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should start an auth stage and reject if no auth flow", async () => {
|
||||
@@ -376,7 +375,7 @@ describe("InteractiveAuth", () => {
|
||||
await expect(ia.attemptAuth.bind(ia)).rejects.toThrow(new Error("No appropriate authentication flow found"));
|
||||
});
|
||||
|
||||
it("should handle unexpected error types without data propery set", async () => {
|
||||
it("should handle unexpected error types without data property set", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
@@ -430,8 +429,8 @@ describe("InteractiveAuth", () => {
|
||||
|
||||
const res = await ia.attemptAuth();
|
||||
expect(res).toBe(requestRes);
|
||||
expect(doRequest).toBeCalledTimes(1);
|
||||
expect(stateUpdated).toBeCalledTimes(0);
|
||||
expect(doRequest).toHaveBeenCalledTimes(1);
|
||||
expect(stateUpdated).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
describe("requestEmailToken", () => {
|
||||
@@ -464,35 +463,6 @@ describe("InteractiveAuth", () => {
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
|
||||
});
|
||||
|
||||
it("increases auth attempts", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
const requestEmailToken = jest.fn();
|
||||
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest,
|
||||
stateUpdated,
|
||||
requestEmailToken,
|
||||
});
|
||||
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
|
||||
requestEmailToken.mockClear();
|
||||
await ia.requestEmailToken();
|
||||
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
|
||||
});
|
||||
|
||||
it("passes errors through", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
@@ -508,7 +478,7 @@ describe("InteractiveAuth", () => {
|
||||
requestEmailToken,
|
||||
});
|
||||
|
||||
await expect(ia.requestEmailToken.bind(ia)).rejects.toThrowError("unspecific network error");
|
||||
await expect(ia.requestEmailToken.bind(ia)).rejects.toThrow("unspecific network error");
|
||||
});
|
||||
|
||||
it("only starts one request at a time", async () => {
|
||||
@@ -546,4 +516,83 @@ describe("InteractiveAuth", () => {
|
||||
expect(ia.getEmailSid()).toEqual(sid);
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritise shorter flows", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Recaptcha, AuthType.Password] }, { stages: [AuthType.Password] }],
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should prioritise flows with entirely supported stages", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: ["com.devture.shared_secret_auth"] }, { stages: [AuthType.Password] }],
|
||||
params: {},
|
||||
},
|
||||
supportedStages: [AuthType.Password],
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should fire stateUpdated callback if with error when encountered", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Password] }],
|
||||
params: {
|
||||
[AuthType.Password]: { param: "aa" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// first we expect a call here
|
||||
stateUpdated.mockImplementation((stage) => {
|
||||
expect(stage).toEqual(AuthType.Password);
|
||||
ia.submitAuthDict({
|
||||
type: AuthType.Password,
|
||||
});
|
||||
});
|
||||
|
||||
// .. which should trigger a call here
|
||||
doRequest.mockRejectedValue(new MatrixError({ errcode: "M_UNKNOWN", error: "This is an error" }));
|
||||
|
||||
await Promise.allSettled([ia.attemptAuth()]);
|
||||
expect(stateUpdated).toHaveBeenCalledWith("m.login.password", {
|
||||
errcode: "M_UNKNOWN",
|
||||
error: "This is an error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { ClientPrefix, MatrixClient } from "../../src";
|
||||
import { SSOAction } from "../../src/@types/auth";
|
||||
import { TestClient } from "../TestClient";
|
||||
|
||||
function createExampleMatrixClient(): MatrixClient {
|
||||
return new MatrixClient({
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Login request", function () {
|
||||
let client: TestClient;
|
||||
|
||||
@@ -57,3 +66,84 @@ describe("SSO login URL", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshToken", () => {
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it("requests the correctly-prefixed /refresh endpoint when server correctly accepts /v3", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
const response = {
|
||||
access_token: "access_token",
|
||||
refresh_token: "refresh_token",
|
||||
expires_in_ms: 30000,
|
||||
};
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), response);
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), () => {
|
||||
throw new Error("/v1/refresh unexpectedly called");
|
||||
});
|
||||
|
||||
const refreshResult = await client.refreshToken("initial_refresh_token");
|
||||
expect(refreshResult).toEqual(response);
|
||||
});
|
||||
|
||||
it("falls back to /v1 when server does not recognized /v3 refresh", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
const response = {
|
||||
access_token: "access_token",
|
||||
refresh_token: "refresh_token",
|
||||
expires_in_ms: 30000,
|
||||
};
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), response);
|
||||
|
||||
const refreshResult = await client.refreshToken("initial_refresh_token");
|
||||
expect(refreshResult).toEqual(response);
|
||||
});
|
||||
|
||||
it("re-raises M_UNRECOGNIZED exceptions from /v1", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
|
||||
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
|
||||
});
|
||||
|
||||
it("re-raises non-M_UNRECOGNIZED exceptions from /v3", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), 429);
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), () => {
|
||||
throw new Error("/v1/refresh unexpectedly called");
|
||||
});
|
||||
|
||||
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
});
|
||||
|
||||
it("re-raises non-M_UNRECOGNIZED exceptions from /v1", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), 429);
|
||||
|
||||
await expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
+778
-133
File diff suppressed because it is too large
Load Diff
@@ -265,11 +265,13 @@ describe("MSC3089Branch", () => {
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
|
||||
expect(stateKey).toEqual(stateKeyOrder[stateFn.mock.calls.length - 1]);
|
||||
if (stateKey === fileEventId) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(content).toMatchObject({
|
||||
retained: true, // canary for copying state
|
||||
active: false,
|
||||
});
|
||||
} else if (stateKey === fileEventId2) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(content).toMatchObject({
|
||||
active: true,
|
||||
version: 2,
|
||||
|
||||
@@ -130,14 +130,8 @@ describe("MSC3089TreeSpace", () => {
|
||||
return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" }));
|
||||
});
|
||||
client.invite = fn;
|
||||
try {
|
||||
await tree.invite(target, false, false);
|
||||
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect((<MatrixError>e).errcode).toEqual("M_FORBIDDEN");
|
||||
}
|
||||
await expect(tree.invite(target, false, false)).rejects.toThrow("MatrixError: Sample Failure");
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -357,13 +351,18 @@ describe("MSC3089TreeSpace", () => {
|
||||
.fn()
|
||||
.mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect([tree.roomId, subspaceId]).toContain(roomId);
|
||||
|
||||
let expectedType: string;
|
||||
let expectedStateKey: string;
|
||||
if (roomId === subspaceId) {
|
||||
expect(eventType).toEqual(EventType.SpaceParent);
|
||||
expect(stateKey).toEqual(tree.roomId);
|
||||
expectedType = EventType.SpaceParent;
|
||||
expectedStateKey = tree.roomId;
|
||||
} else {
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(stateKey).toEqual(subspaceId);
|
||||
expectedType = EventType.SpaceChild;
|
||||
expectedStateKey = subspaceId;
|
||||
}
|
||||
expect(eventType).toEqual(expectedType);
|
||||
expect(stateKey).toEqual(expectedStateKey);
|
||||
expect(content).toMatchObject({ via: [domain] });
|
||||
|
||||
// return value not used
|
||||
@@ -629,15 +628,8 @@ describe("MSC3089TreeSpace", () => {
|
||||
});
|
||||
|
||||
it("should throw when setting an order at the top level space", async () => {
|
||||
try {
|
||||
// The tree is what we've defined as top level, so it should work
|
||||
await tree.setOrder(2);
|
||||
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect((<Error>e).message).toEqual("Cannot set order of top level spaces currently");
|
||||
}
|
||||
// The tree is what we've defined as top level, so it should work
|
||||
await expect(tree.setOrder(2)).rejects.toThrow("Cannot set order of top level spaces currently");
|
||||
});
|
||||
|
||||
it("should return a stable order for unordered children", () => {
|
||||
|
||||
@@ -16,7 +16,8 @@ limitations under the License.
|
||||
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { Crypto } from "../../../src/crypto";
|
||||
import { Crypto, IEventDecryptionResult } from "../../../src/crypto";
|
||||
import { IAnnotatedPushRule, PushRuleActionName, TweakName } from "../../../src";
|
||||
|
||||
describe("MatrixEvent", () => {
|
||||
it("should create copies of itself", () => {
|
||||
@@ -126,12 +127,31 @@ describe("MatrixEvent", () => {
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: Error: test error **",
|
||||
});
|
||||
});
|
||||
|
||||
it(`should report "DecryptionError: The sender has disabled encrypting to unverified devices."`, async () => {
|
||||
const crypto = {
|
||||
decryptEvent: jest
|
||||
.fn()
|
||||
.mockRejectedValue("DecryptionError: The sender has disabled encrypting to unverified devices."),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeTruthy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: DecryptionError: The sender has disabled encrypting to unverified devices. **",
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry decryption if a retry is queued", async () => {
|
||||
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption");
|
||||
|
||||
@@ -163,4 +183,150 @@ describe("MatrixEvent", () => {
|
||||
expect(encryptedEvent.getType()).toEqual("m.room.message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("replyEventId", () => {
|
||||
it("should ignore 'm.relates_to' from encrypted content even if cleartext lacks one", async () => {
|
||||
const eventId = "test_encrypted_event";
|
||||
const encryptedEvent = new MatrixEvent({
|
||||
event_id: eventId,
|
||||
type: "m.room.encrypted",
|
||||
content: {
|
||||
ciphertext: "secrets",
|
||||
},
|
||||
});
|
||||
|
||||
const crypto = {
|
||||
decryptEvent: jest.fn().mockImplementationOnce(() => {
|
||||
return Promise.resolve<IEventDecryptionResult>({
|
||||
clearEvent: {
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "!anotherEvent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.getType()).toEqual("m.room.message");
|
||||
expect(encryptedEvent.replyEventId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("push details", () => {
|
||||
const pushRule = {
|
||||
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
|
||||
pattern: "banana",
|
||||
rule_id: "banana",
|
||||
kind: "override",
|
||||
default: false,
|
||||
enabled: true,
|
||||
} as IAnnotatedPushRule;
|
||||
describe("setPushActions()", () => {
|
||||
it("sets actions on event", () => {
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushActions(actions);
|
||||
|
||||
expect(event.getPushActions()).toBe(actions);
|
||||
});
|
||||
|
||||
it("sets actions to undefined", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushActions(null);
|
||||
|
||||
// undefined is set on state
|
||||
expect(event.getPushDetails().actions).toBe(undefined);
|
||||
// but pushActions getter returns null when falsy
|
||||
expect(event.getPushActions()).toBe(null);
|
||||
});
|
||||
|
||||
it("clears existing push rule", () => {
|
||||
const prevActions = { notify: true, tweaks: { highlight: true } };
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushDetails(prevActions, pushRule);
|
||||
|
||||
event.setPushActions(actions);
|
||||
|
||||
// rule is not in event push cache
|
||||
expect(event.getPushDetails()).toEqual({ actions });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPushDetails()", () => {
|
||||
it("sets actions and rule on event", () => {
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushDetails(actions, pushRule);
|
||||
|
||||
expect(event.getPushDetails()).toEqual({
|
||||
actions,
|
||||
rule: pushRule,
|
||||
});
|
||||
});
|
||||
it("clears existing push rule", () => {
|
||||
const prevActions = { notify: true, tweaks: { highlight: true } };
|
||||
const actions = { notify: false, tweaks: {} };
|
||||
const event = new MatrixEvent({
|
||||
type: "com.example.test",
|
||||
content: {
|
||||
isTest: true,
|
||||
},
|
||||
});
|
||||
event.setPushDetails(prevActions, pushRule);
|
||||
|
||||
event.setPushActions(actions);
|
||||
|
||||
// rule is not in event push cache
|
||||
expect(event.getPushDetails()).toEqual({ actions });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore thread relation on state events", async () => {
|
||||
const stateEvent = new MatrixEvent({
|
||||
event_id: "$event_id",
|
||||
type: "some_state_event",
|
||||
content: {
|
||||
"foo": "bar",
|
||||
"m.relates_to": {
|
||||
"event_id": "$thread_id",
|
||||
"m.in_reply_to": {
|
||||
event_id: "$thread_id",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
state_key: "",
|
||||
});
|
||||
|
||||
expect(stateEvent.isState()).toBeTruthy();
|
||||
expect(stateEvent.threadRootId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
Copyright 2023 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 { M_POLL_START } from "matrix-events-sdk";
|
||||
|
||||
import { EventType, IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
|
||||
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
|
||||
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls";
|
||||
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
|
||||
import { isPollEvent, Poll } from "../../../src/models/poll";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { mkEvent } from "../../test-utils/test-utils";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe("Poll", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(true),
|
||||
relations: jest.fn(),
|
||||
});
|
||||
const roomId = "!room:server";
|
||||
const room = new Room(roomId, mockClient, userId);
|
||||
const maySendRedactionForEventSpy = jest.spyOn(room.currentState, "maySendRedactionForEvent");
|
||||
// 14.03.2022 16:15
|
||||
const now = 1647270879403;
|
||||
|
||||
const basePollStartEvent = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
sender: userId,
|
||||
});
|
||||
basePollStartEvent.event.event_id = "$12345";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.setSystemTime(now);
|
||||
|
||||
mockClient.relations.mockReset().mockResolvedValue({ events: [] });
|
||||
|
||||
maySendRedactionForEventSpy.mockClear().mockReturnValue(true);
|
||||
});
|
||||
|
||||
let eventId = 1;
|
||||
const makeRelatedEvent = (eventProps: Partial<IEvent>, timestamp = now): MatrixEvent => {
|
||||
const event = new MatrixEvent({
|
||||
...eventProps,
|
||||
content: {
|
||||
...(eventProps.content || {}),
|
||||
"m.relates_to": {
|
||||
rel_type: REFERENCE_RELATION.name,
|
||||
event_id: basePollStartEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
event.event.origin_server_ts = timestamp;
|
||||
event.event.event_id = `${eventId++}`;
|
||||
return event;
|
||||
};
|
||||
|
||||
it("initialises with root event", () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
expect(poll.roomId).toEqual(roomId);
|
||||
expect(poll.pollId).toEqual(basePollStartEvent.getId());
|
||||
expect(poll.pollEvent).toEqual(basePollStartEvent.unstableExtensibleEvent);
|
||||
expect(poll.isEnded).toBe(false);
|
||||
expect(poll.endEventId).toBe(undefined);
|
||||
});
|
||||
|
||||
it("throws when poll start has no room id", () => {
|
||||
const pollStartEvent = new MatrixEvent(
|
||||
PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
);
|
||||
expect(() => new Poll(pollStartEvent, mockClient, room)).toThrow("Invalid poll start event.");
|
||||
});
|
||||
|
||||
it("throws when poll start has no event id", () => {
|
||||
const pollStartEvent = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
});
|
||||
expect(() => new Poll(pollStartEvent, mockClient, room)).toThrow("Invalid poll start event.");
|
||||
});
|
||||
|
||||
describe("fetching responses", () => {
|
||||
it("calls relations api and emits", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const emitSpy = jest.spyOn(poll, "emit");
|
||||
const fetchResponsePromise = poll.getResponses();
|
||||
expect(poll.isFetchingResponses).toBe(true);
|
||||
const responses = await fetchResponsePromise;
|
||||
expect(poll.isFetchingResponses).toBe(false);
|
||||
expect(mockClient.relations).toHaveBeenCalledWith(
|
||||
roomId,
|
||||
basePollStartEvent.getId(),
|
||||
"m.reference",
|
||||
undefined,
|
||||
{ from: undefined },
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
});
|
||||
|
||||
it("returns existing responses object after initial fetch", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
const responses2 = await poll.getResponses();
|
||||
// only fetched relations once
|
||||
expect(mockClient.relations).toHaveBeenCalledTimes(1);
|
||||
// strictly equal
|
||||
expect(responses).toBe(responses2);
|
||||
});
|
||||
|
||||
it("waits for existing relations request to finish when getting responses", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const firstResponsePromise = poll.getResponses();
|
||||
const secondResponsePromise = poll.getResponses();
|
||||
await firstResponsePromise;
|
||||
expect(firstResponsePromise).toEqual(secondResponsePromise);
|
||||
await secondResponsePromise;
|
||||
expect(mockClient.relations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters relations for relevent response events", async () => {
|
||||
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
|
||||
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
|
||||
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
|
||||
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [replyEvent, stableResponseEvent, unstableResponseEvent],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]);
|
||||
});
|
||||
|
||||
describe("with multiple pages of relations", () => {
|
||||
const makeResponses = (count = 1, timestamp = now): MatrixEvent[] =>
|
||||
new Array(count)
|
||||
.fill("x")
|
||||
.map((_x, index) =>
|
||||
makeRelatedEvent(
|
||||
{ type: M_POLL_RESPONSE.stable!, sender: "@bob@server.org" },
|
||||
timestamp + index,
|
||||
),
|
||||
);
|
||||
|
||||
it("page relations responses", async () => {
|
||||
const responseEvents = makeResponses(6);
|
||||
mockClient.relations
|
||||
.mockResolvedValueOnce({
|
||||
events: responseEvents.slice(0, 2),
|
||||
nextBatch: "test-next-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
events: responseEvents.slice(2, 4),
|
||||
nextBatch: "test-next-2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
events: responseEvents.slice(4),
|
||||
});
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(mockClient.relations.mock.calls).toEqual([
|
||||
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: undefined }],
|
||||
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-1" }],
|
||||
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-2" }],
|
||||
]);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledTimes(3);
|
||||
expect(poll.isFetchingResponses).toBeFalsy();
|
||||
expect(responses.getRelations().length).toEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undecryptable relations", () => {
|
||||
it("counts undecryptable relation events when getting responses", async () => {
|
||||
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
|
||||
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
|
||||
const undecryptableEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
|
||||
jest.spyOn(undecryptableEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [replyEvent, stableResponseEvent, undecryptableEvent],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
expect(poll.undecryptableRelationsCount).toBe(1);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.UndecryptableRelations, 1);
|
||||
});
|
||||
|
||||
it("adds to undercryptable event count when new relation is undecryptable", async () => {
|
||||
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
|
||||
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
|
||||
const undecryptableEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
|
||||
const undecryptableEvent2 = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
|
||||
jest.spyOn(undecryptableEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||
jest.spyOn(undecryptableEvent2, "isDecryptionFailure").mockReturnValue(true);
|
||||
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [replyEvent, stableResponseEvent, undecryptableEvent],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
expect(poll.undecryptableRelationsCount).toBe(1);
|
||||
|
||||
await poll.onNewRelation(undecryptableEvent2);
|
||||
|
||||
expect(poll.undecryptableRelationsCount).toBe(2);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.UndecryptableRelations, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with poll end event", () => {
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob@server.org" });
|
||||
const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable!, sender: "@bob@server.org" });
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, stablePollEndEvent],
|
||||
});
|
||||
});
|
||||
|
||||
it("sets poll end event with stable event type", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
|
||||
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org");
|
||||
expect(poll.isEnded).toBe(true);
|
||||
expect(poll.endEventId).toBe(stablePollEndEvent.getId()!);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("sets poll end event when endevent sender also created the poll, but does not have redaction rights", async () => {
|
||||
const pollStartEvent = new MatrixEvent({
|
||||
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
|
||||
room_id: roomId,
|
||||
sender: "@bob:domain.org",
|
||||
});
|
||||
pollStartEvent.event.event_id = "$6789";
|
||||
const poll = new Poll(pollStartEvent, mockClient, room);
|
||||
const pollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob:domain.org" });
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [pollEndEvent],
|
||||
});
|
||||
maySendRedactionForEventSpy.mockReturnValue(false);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
|
||||
expect(maySendRedactionForEventSpy).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBe(true);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("sets poll end event with unstable event type", async () => {
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [unstablePollEndEvent],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
await poll.getResponses();
|
||||
|
||||
expect(poll.isEnded).toBe(true);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("filters out responses that were sent after poll end", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
// just response type events
|
||||
// and response with ts after poll end event is excluded
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNewRelation()", () => {
|
||||
it("discards response if poll responses have not been initialised", () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
|
||||
poll.onNewRelation(responseEvent);
|
||||
|
||||
// did not add response -> no emit
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets poll end event when responses are not initialised", () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
jest.spyOn(poll, "emit");
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
|
||||
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
});
|
||||
|
||||
it("does not set poll end event when sent by invalid user", async () => {
|
||||
maySendRedactionForEventSpy.mockReturnValue(false);
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" });
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
// didn't end, didn't refilter responses
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBeFalsy();
|
||||
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org");
|
||||
});
|
||||
|
||||
it("replaces poll end event and refilters when an older end event already exists", async () => {
|
||||
const earlierPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now,
|
||||
);
|
||||
const laterPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now + 2000,
|
||||
);
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, laterPollEndEvent],
|
||||
});
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
|
||||
// all responses have a timestamp < laterPollEndEvent
|
||||
expect(responses.getRelations().length).toEqual(3);
|
||||
// first end event set correctly
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
|
||||
// reset spy count
|
||||
jest.spyOn(poll, "emit").mockClear();
|
||||
|
||||
// add a valid end event with earlier timestamp
|
||||
poll.onNewRelation(earlierPollEndEvent);
|
||||
|
||||
// emitted new end event
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
// filtered responses and emitted
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
|
||||
it("does not set poll end event when an earlier end event already exists", async () => {
|
||||
const earlierPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now,
|
||||
);
|
||||
const laterPollEndEvent = makeRelatedEvent(
|
||||
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
|
||||
now + 2000,
|
||||
);
|
||||
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
await poll.getResponses();
|
||||
|
||||
poll.onNewRelation(earlierPollEndEvent);
|
||||
|
||||
// first end event set correctly
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
|
||||
// reset spy count
|
||||
jest.spyOn(poll, "emit").mockClear();
|
||||
|
||||
poll.onNewRelation(laterPollEndEvent);
|
||||
// didn't set new end event, didn't refilter responses
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(poll.isEnded).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets poll end event and refilters responses based on timestamp", async () => {
|
||||
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
|
||||
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
|
||||
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
|
||||
mockClient.relations.mockResolvedValue({
|
||||
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd],
|
||||
});
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
const responses = await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
|
||||
expect(responses.getRelations().length).toEqual(3);
|
||||
poll.onNewRelation(stablePollEndEvent);
|
||||
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations().length).toEqual(2);
|
||||
// after end timestamp event is removed
|
||||
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
|
||||
});
|
||||
|
||||
it("filters out irrelevant relations", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
// init responses
|
||||
const responses = await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
const replyEvent = new MatrixEvent({ type: "m.room.message" });
|
||||
|
||||
poll.onNewRelation(replyEvent);
|
||||
|
||||
// did not add response -> no emit
|
||||
expect(poll.emit).not.toHaveBeenCalled();
|
||||
expect(responses.getRelations().length).toEqual(0);
|
||||
});
|
||||
|
||||
it("adds poll response relations to responses", async () => {
|
||||
const poll = new Poll(basePollStartEvent, mockClient, room);
|
||||
// init responses
|
||||
const responses = await poll.getResponses();
|
||||
jest.spyOn(poll, "emit");
|
||||
const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
|
||||
|
||||
poll.onNewRelation(responseEvent);
|
||||
|
||||
// did not add response -> no emit
|
||||
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
|
||||
expect(responses.getRelations()).toEqual([responseEvent]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPollEvent", () => {
|
||||
it("should return »false« for a non-poll event", () => {
|
||||
const messageEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
content: {},
|
||||
user: mockClient.getSafeUserId(),
|
||||
room: room.roomId,
|
||||
});
|
||||
expect(isPollEvent(messageEvent)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([[M_POLL_START.name], [M_POLL_RESPONSE.name], [M_POLL_END.name]])(
|
||||
"should return »true« for a »%s« event",
|
||||
(type: string) => {
|
||||
const pollEvent = mkEvent({
|
||||
event: true,
|
||||
type,
|
||||
content: {},
|
||||
user: mockClient.getSafeUserId(),
|
||||
room: room.roomId,
|
||||
});
|
||||
expect(isPollEvent(pollEvent)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { mkThread } from "../../test-utils/thread";
|
||||
import { Room, RoomEvent } from "../../../src/models/room";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, MatrixEvent } from "../../../src";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
|
||||
import { ReceiptType } from "../../../src/@types/read_receipts";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
import { ReEmitter } from "../../../src/ReEmitter";
|
||||
import { Feature, ServerSupport } from "../../../src/feature";
|
||||
import { eventMapperFor } from "../../../src/event-mapper";
|
||||
|
||||
describe("Thread", () => {
|
||||
describe("constructor", () => {
|
||||
@@ -40,6 +43,7 @@ describe("Thread", () => {
|
||||
const myUserId = "@bob:example.org";
|
||||
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, { timelineSupport: false });
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -82,9 +86,10 @@ describe("Thread", () => {
|
||||
beforeEach(() => {
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
isInitialSyncComplete: jest.fn().mockReturnValue(false),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
@@ -193,9 +198,10 @@ describe("Thread", () => {
|
||||
beforeEach(() => {
|
||||
client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
isInitialSyncComplete: jest.fn().mockReturnValue(false),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = new Map();
|
||||
@@ -295,6 +301,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -349,6 +356,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -400,6 +408,7 @@ describe("Thread", () => {
|
||||
timelineSupport: false,
|
||||
});
|
||||
const client = testClient.client;
|
||||
client.supportsThreads = jest.fn().mockReturnValue(true);
|
||||
const room = new Room("123", client, myUserId, {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
@@ -422,4 +431,301 @@ describe("Thread", () => {
|
||||
expect(mock).toHaveBeenCalledWith("b1", "f1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertEventIntoTimeline", () => {
|
||||
it("Inserts a reaction in timestamp order", () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
const client = createClientWithEventMapper();
|
||||
const userId = "user1";
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread with a root plus 5 messages
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: userId,
|
||||
participantUserIds: ["@bob:hs", "@chia:hs", "@dv:hs"],
|
||||
length: 6,
|
||||
ts: 100, // Events will be at ts 100, 101, 102, 103, 104 and 105
|
||||
});
|
||||
|
||||
// When we insert a reaction to the second thread message
|
||||
const replyEvent = mkReaction(events[2], client, userId, room.roomId, 104);
|
||||
thread.insertEventIntoTimeline(replyEvent);
|
||||
|
||||
// Then the reaction is inserted based on its timestamp
|
||||
expect(thread.events.map((ev) => ev.getId())).toEqual([
|
||||
events[0].getId(),
|
||||
events[1].getId(),
|
||||
events[2].getId(),
|
||||
events[3].getId(),
|
||||
events[4].getId(),
|
||||
replyEvent.getId(),
|
||||
events[5].getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Without relations recursion support", () => {
|
||||
it("Creates a local echo receipt for new events", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client without relations recursion support
|
||||
const client = createClientWithEventMapper();
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.data.ts).toEqual(100);
|
||||
expect(receipt?.data.thread_id).toEqual(thread.id);
|
||||
|
||||
// (And the receipt was synthetic)
|
||||
expect(thread.getReadReceiptForUserId(userId, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("Doesn't create a local echo receipt for events before an existing receipt", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client without relations recursion support
|
||||
const client = createClientWithEventMapper();
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
|
||||
// Then no receipt was added to the thread (the receipt is still
|
||||
// for the thread root). This happens because since we have no
|
||||
// recursive relations support, we know that sometimes events
|
||||
// appear out of order, so we have to check their timestamps as
|
||||
// a guess of the correct order.
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("With relations recursion support", () => {
|
||||
it("Creates a local echo receipt for new events", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client WITH relations recursion support
|
||||
const client = createClientWithEventMapper(
|
||||
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
|
||||
);
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
});
|
||||
|
||||
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client WITH relations recursion support
|
||||
const client = createClientWithEventMapper(
|
||||
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
|
||||
);
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread, because relations
|
||||
// recursion is available, so we trust the server to have
|
||||
// provided us with events in the right order.
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
});
|
||||
});
|
||||
|
||||
async function createThreadAndEvent(
|
||||
client: MatrixClient,
|
||||
rootTs: number,
|
||||
eventTs: number,
|
||||
userId: string,
|
||||
): Promise<{ thread: Thread; message: MatrixEvent }> {
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread
|
||||
const { thread } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: userId,
|
||||
participantUserIds: [],
|
||||
ts: rootTs,
|
||||
});
|
||||
// Sanity: the current receipt is for the thread root
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
|
||||
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
|
||||
|
||||
// When we add a message that is before the latest receipt
|
||||
const message = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: eventTs,
|
||||
});
|
||||
await thread.addEvent(message, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message };
|
||||
}
|
||||
|
||||
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
|
||||
const client = mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = canSupport;
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
mocked(client.supportsThreads).mockReturnValue(true);
|
||||
return client;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Editing events", () => {
|
||||
describe("Given server support for threads", () => {
|
||||
let previousThreadHasServerSideSupport: FeatureSupport;
|
||||
|
||||
beforeAll(() => {
|
||||
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
|
||||
});
|
||||
|
||||
it("Adds edits from sync to the thread timeline and applies them", async () => {
|
||||
// Given a thread
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
|
||||
// When a message and an edit are added to the thread
|
||||
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
|
||||
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
|
||||
await thread.addEvent(messageToEdit, false);
|
||||
await thread.addEvent(editEvent, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(editEvent);
|
||||
expect(secondLastEvent).toBe(messageToEdit);
|
||||
|
||||
// And the first message has been edited
|
||||
expect(secondLastEvent.getContent().body).toEqual("edit");
|
||||
});
|
||||
|
||||
it("Adds edits fetched on demand to the thread timeline and applies them", async () => {
|
||||
// Given we don't support recursive relations
|
||||
const client = createClient(new Map([[Feature.RelationsRecursion, ServerSupport.Unsupported]]));
|
||||
// And we have a thread
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
|
||||
// When a message is added to the thread, and an edit to it is provided on demand
|
||||
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
|
||||
// (fetchEditsWhereNeeded only applies to encrypted messages for some reason)
|
||||
messageToEdit.event.type = EventType.RoomMessageEncrypted;
|
||||
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
|
||||
mocked(client.relations).mockImplementation(async (_roomId, eventId) => {
|
||||
if (eventId === messageToEdit.getId()) {
|
||||
return { events: [editEvent] };
|
||||
} else {
|
||||
return { events: [] };
|
||||
}
|
||||
});
|
||||
await thread.addEvent(messageToEdit, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(editEvent);
|
||||
expect(secondLastEvent).toBe(messageToEdit);
|
||||
|
||||
// And the first message has been edited
|
||||
expect(secondLastEvent.getContent().body).toEqual("edit");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a message event that lives in a thread
|
||||
*/
|
||||
function createThreadMessage(threadId: string, user: string, room: string, msg: string): MatrixEvent {
|
||||
return makeThreadEvent({
|
||||
event: true,
|
||||
user,
|
||||
room,
|
||||
msg,
|
||||
rootEventId: threadId,
|
||||
replyToEventId: threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread and wait for it to be properly initialised (so you can safely
|
||||
* add events to it and expect them to appear in the timeline.
|
||||
*/
|
||||
async function createThread(client: MatrixClient, user: string, roomId: string): Promise<Thread> {
|
||||
const root = mkMessage({ event: true, user, room: roomId, msg: "Thread root" });
|
||||
const room = new Room(roomId, client, "@roomcreator:x");
|
||||
|
||||
// Ensure the root is in the room timeline
|
||||
root.setThreadId(root.getId());
|
||||
await room.addLiveEvents([root]);
|
||||
|
||||
// Create the thread and wait for it to be initialised
|
||||
const thread = room.createThread(root.getId()!, root, [], false);
|
||||
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MatrixClient that supports threads and has all the methods used when
|
||||
* creating a thread that call out to HTTP endpoints mocked out.
|
||||
*/
|
||||
function createClient(canSupport = new Map()): MatrixClient {
|
||||
const client = mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = canSupport;
|
||||
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
|
||||
// Mock methods that call out to HTTP endpoints
|
||||
jest.spyOn(client, "paginateEventTimeline").mockResolvedValue(true);
|
||||
jest.spyOn(client, "relations").mockResolvedValue({ events: [] });
|
||||
jest.spyOn(client, "fetchRoomEvent").mockResolvedValue({});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -53,10 +53,11 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
isInitialSyncComplete: jest.fn().mockReturnValue(false),
|
||||
getPushActionsForEvent: jest.fn().mockReturnValue(mkPushAction(true, true)),
|
||||
getRoom: jest.fn().mockImplementation(() => room),
|
||||
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
|
||||
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
mockClient.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
mockClient.canSupport = new Map();
|
||||
@@ -134,7 +135,7 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
|
||||
fixNotificationCountOnDecryption(mockClient, event);
|
||||
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(2);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(3);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(1);
|
||||
});
|
||||
|
||||
@@ -154,11 +155,11 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
|
||||
fixNotificationCountOnDecryption(mockClient, threadEvent);
|
||||
|
||||
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(1);
|
||||
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(2);
|
||||
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not change the room count when there's no unread count", () => {
|
||||
it("does not change the thread count when there's no unread count", () => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
|
||||
|
||||
@@ -192,6 +193,31 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not change the total room count when an event is marked as non-notifying", () => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 0);
|
||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
|
||||
|
||||
event.getPushActions = jest.fn().mockReturnValue(mkPushAction(true, false));
|
||||
mockClient.getPushActionsForEvent = jest.fn().mockReturnValue(mkPushAction(false, false));
|
||||
|
||||
fixNotificationCountOnDecryption(mockClient, event);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not change the total room count when a threaded event is marked as non-notifying", () => {
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
|
||||
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
|
||||
|
||||
threadEvent.getPushActions = jest.fn().mockReturnValue(mkPushAction(true, false));
|
||||
mockClient.getPushActionsForEvent = jest.fn().mockReturnValue(mkPushAction(false, false));
|
||||
|
||||
fixNotificationCountOnDecryption(mockClient, event);
|
||||
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total)).toBe(0);
|
||||
expect(room.getThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight)).toBe(0);
|
||||
});
|
||||
|
||||
it("emits events", () => {
|
||||
const cb = jest.fn();
|
||||
room.on(RoomEvent.UnreadNotifications, cb);
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
Copyright 2023 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 fetchMock from "fetch-mock-jest";
|
||||
import { mocked } from "jest-mock";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
import { Method } from "../../../src";
|
||||
import * as crypto from "../../../src/crypto/crypto";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
completeAuthorizationCodeGrant,
|
||||
generateAuthorizationParams,
|
||||
generateAuthorizationUrl,
|
||||
generateOidcAuthorizationUrl,
|
||||
} from "../../../src/oidc/authorize";
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-utils/oidc";
|
||||
|
||||
jest.mock("jwt-decode");
|
||||
|
||||
// save for resetting mocks
|
||||
const realSubtleCrypto = crypto.subtleCrypto;
|
||||
|
||||
describe("oidc authorization", () => {
|
||||
const delegatedAuthConfig = makeDelegatedAuthConfig();
|
||||
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint;
|
||||
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint;
|
||||
const clientId = "xyz789";
|
||||
const baseUrl = "https://test.com";
|
||||
|
||||
// 14.03.2022 16:15
|
||||
const now = 1647270879403;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(logger, "warn");
|
||||
jest.setSystemTime(now);
|
||||
|
||||
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-ignore reset any ugly mocking we did
|
||||
crypto.subtleCrypto = realSubtleCrypto;
|
||||
});
|
||||
|
||||
it("should generate authorization params", () => {
|
||||
const result = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
|
||||
expect(result.redirectUri).toEqual(baseUrl);
|
||||
|
||||
// random strings
|
||||
expect(result.state.length).toEqual(8);
|
||||
expect(result.nonce.length).toEqual(8);
|
||||
expect(result.codeVerifier.length).toEqual(64);
|
||||
|
||||
const expectedScope =
|
||||
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:";
|
||||
expect(result.scope.startsWith(expectedScope)).toBeTruthy();
|
||||
// deviceId of 10 characters is appended to the device scope
|
||||
expect(result.scope.length).toEqual(expectedScope.length + 10);
|
||||
});
|
||||
|
||||
describe("generateAuthorizationUrl()", () => {
|
||||
it("should generate url with correct parameters", async () => {
|
||||
// test the no crypto case here
|
||||
// @ts-ignore mocking
|
||||
crypto.subtleCrypto = undefined;
|
||||
|
||||
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
|
||||
const authUrl = new URL(
|
||||
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
|
||||
expect(authUrl.searchParams.get("response_type")).toEqual("code");
|
||||
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
|
||||
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
|
||||
expect(authUrl.searchParams.get("scope")).toEqual(authorizationParams.scope);
|
||||
expect(authUrl.searchParams.get("state")).toEqual(authorizationParams.state);
|
||||
expect(authUrl.searchParams.get("nonce")).toEqual(authorizationParams.nonce);
|
||||
|
||||
// crypto not available, plain text code_challenge is used
|
||||
expect(authUrl.searchParams.get("code_challenge")).toEqual(authorizationParams.codeVerifier);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"A secure context is required to generate code challenge. Using plain text code challenge",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOidcAuthorizationUrl()", () => {
|
||||
it("should generate url with correct parameters", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const metadata = delegatedAuthConfig.metadata;
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
|
||||
expect(authUrl.searchParams.get("response_type")).toEqual("code");
|
||||
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
|
||||
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
|
||||
// scope minus the 10char random device id at the end
|
||||
expect(authUrl.searchParams.get("scope")!.slice(0, -10)).toEqual(
|
||||
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:",
|
||||
);
|
||||
expect(authUrl.searchParams.get("state")).toBeTruthy();
|
||||
expect(authUrl.searchParams.get("nonce")).toEqual(nonce);
|
||||
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
const homeserverUrl = "https://server.org/";
|
||||
const identityServerUrl = "https://id.org/";
|
||||
const nonce = "test-nonce";
|
||||
const redirectUri = baseUrl;
|
||||
const code = "auth_code_xyz";
|
||||
const validBearerTokenResponse = {
|
||||
token_type: "Bearer",
|
||||
access_token: "test_access_token",
|
||||
refresh_token: "test_refresh_token",
|
||||
id_token: "valid.id.token",
|
||||
expires_in: 300,
|
||||
};
|
||||
|
||||
const metadata = mockOpenIdConfiguration();
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
// not expired
|
||||
exp: Date.now() / 1000 + 100000,
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
iss: metadata.issuer,
|
||||
sub: "123",
|
||||
};
|
||||
|
||||
const mockSessionStorage = (state: Record<string, unknown>): void => {
|
||||
jest.spyOn(sessionStorage.__proto__, "getItem").mockImplementation((key: unknown) => {
|
||||
return state[key as string] ?? null;
|
||||
});
|
||||
jest.spyOn(sessionStorage.__proto__, "setItem").mockImplementation(
|
||||
// @ts-ignore mock type
|
||||
(key: string, value: unknown) => (state[key] = value),
|
||||
);
|
||||
jest.spyOn(sessionStorage.__proto__, "removeItem").mockImplementation((key: unknown) => {
|
||||
const { [key as string]: value, ...newState } = state;
|
||||
state = newState;
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
const getValueFromStorage = <T = string>(state: string, key: string): T => {
|
||||
const storedState = window.sessionStorage.getItem(`mx_oidc_${state}`)!;
|
||||
return JSON.parse(storedState)[key] as unknown as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* These tests kind of integration test oidc auth, by using `generateOidcAuthorizationUrl` and mocked storage
|
||||
* to mock the use case of initiating oidc auth, putting state in storage, redirecting to OP,
|
||||
* then returning and using state to verfiy.
|
||||
* Returns random state string used to access storage
|
||||
* @param params
|
||||
*/
|
||||
const setupState = async (params = {}): Promise<string> => {
|
||||
const url = await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
redirectUri,
|
||||
clientId,
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
nonce,
|
||||
...params,
|
||||
});
|
||||
|
||||
const state = new URL(url).searchParams.get("state")!;
|
||||
|
||||
// add the scope with correct deviceId to the mocked bearer token response
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
fetchMock.post(metadata.token_endpoint, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...validBearerTokenResponse,
|
||||
scope,
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
fetchMock.resetBehavior();
|
||||
|
||||
fetchMock.get(`${metadata.issuer}.well-known/openid-configuration`, metadata);
|
||||
fetchMock.get(`${metadata.issuer}jwks`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
|
||||
mockSessionStorage({});
|
||||
|
||||
mocked(jwtDecode).mockReturnValue(validDecodedIdToken);
|
||||
});
|
||||
|
||||
it("should make correct request to the token endpoint", async () => {
|
||||
const state = await setupState();
|
||||
const codeVerifier = getValueFromStorage(state, "code_verifier");
|
||||
await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
metadata.token_endpoint,
|
||||
expect.objectContaining({
|
||||
method: Method.Post,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// check body is correctly formed
|
||||
const queryParams = fetchMock.mock.calls.find(([endpoint]) => endpoint === metadata.token_endpoint)![1]!
|
||||
.body as URLSearchParams;
|
||||
expect(queryParams.get("grant_type")).toEqual("authorization_code");
|
||||
expect(queryParams.get("client_id")).toEqual(clientId);
|
||||
expect(queryParams.get("code_verifier")).toEqual(codeVerifier);
|
||||
expect(queryParams.get("redirect_uri")).toEqual(redirectUri);
|
||||
expect(queryParams.get("code")).toEqual(code);
|
||||
});
|
||||
|
||||
it("should return with valid bearer token", async () => {
|
||||
const state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
const result = await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
expect(result).toEqual({
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
oidcClientSettings: {
|
||||
clientId,
|
||||
issuer: metadata.issuer,
|
||||
},
|
||||
tokenResponse: {
|
||||
access_token: validBearerTokenResponse.access_token,
|
||||
id_token: validBearerTokenResponse.id_token,
|
||||
refresh_token: validBearerTokenResponse.refresh_token,
|
||||
token_type: validBearerTokenResponse.token_type,
|
||||
// this value is slightly unstable because it uses the clock
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return with valid bearer token where token_type is lowercase", async () => {
|
||||
const state = await setupState();
|
||||
const scope = getValueFromStorage(state, "scope");
|
||||
const tokenResponse = {
|
||||
...validBearerTokenResponse,
|
||||
scope,
|
||||
token_type: "bearer",
|
||||
};
|
||||
fetchMock.post(
|
||||
tokenEndpoint,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...tokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const result = await completeAuthorizationCodeGrant(code, state);
|
||||
|
||||
expect(result).toEqual({
|
||||
homeserverUrl,
|
||||
identityServerUrl,
|
||||
oidcClientSettings: {
|
||||
clientId,
|
||||
issuer: metadata.issuer,
|
||||
},
|
||||
// results in token that uses 'Bearer' token type
|
||||
tokenResponse: {
|
||||
access_token: validBearerTokenResponse.access_token,
|
||||
id_token: validBearerTokenResponse.id_token,
|
||||
refresh_token: validBearerTokenResponse.refresh_token,
|
||||
token_type: "Bearer",
|
||||
// this value is slightly unstable because it uses the clock
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
});
|
||||
|
||||
it("should throw when state is not found in storage", async () => {
|
||||
// don't setup sessionStorage with expected state
|
||||
const state = "abc123";
|
||||
fetchMock.post(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.MissingOrInvalidStoredState),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw with code exchange failed error when request fails", async () => {
|
||||
const state = await setupState();
|
||||
fetchMock.post(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.CodeExchangeFailed),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw invalid token error when token is invalid", async () => {
|
||||
const state = await setupState();
|
||||
const invalidBearerTokenResponse = {
|
||||
...validBearerTokenResponse,
|
||||
access_token: null,
|
||||
};
|
||||
fetchMock.post(
|
||||
metadata.token_endpoint,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...invalidBearerTokenResponse,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.InvalidBearerTokenResponse),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw invalid id token error when id_token is invalid", async () => {
|
||||
const state = await setupState();
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
// invalid audience
|
||||
aud: "something-else",
|
||||
});
|
||||
await expect(() => completeAuthorizationCodeGrant(code, state)).rejects.toThrow(
|
||||
new Error(OidcError.InvalidIdToken),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright 2023 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 fetchMockJest from "fetch-mock-jest";
|
||||
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
import { registerOidcClient } from "../../../src/oidc/register";
|
||||
|
||||
describe("registerOidcClient()", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const registrationEndpoint = "https://auth.com/register";
|
||||
const clientName = "Element";
|
||||
const baseUrl = "https://just.testing";
|
||||
const dynamicClientId = "xyz789";
|
||||
|
||||
const delegatedAuthConfig = {
|
||||
issuer,
|
||||
registrationEndpoint,
|
||||
authorizationEndpoint: issuer + "auth",
|
||||
tokenEndpoint: issuer + "token",
|
||||
};
|
||||
beforeEach(() => {
|
||||
fetchMockJest.mockClear();
|
||||
fetchMockJest.resetBehavior();
|
||||
});
|
||||
|
||||
it("should make correct request to register client", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 200,
|
||||
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||
});
|
||||
expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId);
|
||||
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, {
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_name: clientName,
|
||||
client_uri: baseUrl,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
redirect_uris: [baseUrl],
|
||||
id_token_signed_response_alg: "RS256",
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when registration request fails", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 500,
|
||||
});
|
||||
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationFailed,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when registration response is invalid", async () => {
|
||||
fetchMockJest.post(registrationEndpoint, {
|
||||
status: 200,
|
||||
// no clientId in response
|
||||
body: "{}",
|
||||
});
|
||||
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
|
||||
OidcError.DynamicRegistrationInvalid,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
Copyright 2023 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 { mocked } from "jest-mock";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
import { M_AUTHENTICATION } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
validateIdToken,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "../../../src/oidc/validate";
|
||||
import { OidcError } from "../../../src/oidc/error";
|
||||
|
||||
jest.mock("jwt-decode");
|
||||
|
||||
describe("validateWellKnownAuthentication()", () => {
|
||||
const baseWk = {
|
||||
"m.homeserver": {
|
||||
base_url: "https://hs.org",
|
||||
},
|
||||
};
|
||||
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
||||
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: { url: "test.com" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is not a string", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
account: { url: "test" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is false", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
account: false,
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
|
||||
OidcError.Misconfigured,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses stable m.authentication", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return valid config when m.authentication account is missing", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove unexpected properties", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
somethingElse: "test",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOIDCIssuerWellKnown", () => {
|
||||
const validWk: any = {
|
||||
authorization_endpoint: "https://test.org/authorize",
|
||||
token_endpoint: "https://authorize.org/token",
|
||||
registration_endpoint: "https://authorize.org/regsiter",
|
||||
revocation_endpoint: "https://authorize.org/regsiter",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
};
|
||||
beforeEach(() => {
|
||||
// stub to avoid console litter
|
||||
jest.spyOn(logger, "error")
|
||||
.mockClear()
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("should throw OP support error when wellKnown is not an object", () => {
|
||||
expect(() => {
|
||||
validateOIDCIssuerWellKnown([]);
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
|
||||
});
|
||||
|
||||
it("should log all errors before throwing", () => {
|
||||
expect(() => {
|
||||
validateOIDCIssuerWellKnown({
|
||||
...validWk,
|
||||
authorization_endpoint: undefined,
|
||||
response_types_supported: [],
|
||||
});
|
||||
}).toThrow(OidcError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("Missing or invalid property: authorization_endpoint");
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid property: response_types_supported. code is required.");
|
||||
});
|
||||
|
||||
it("should return validated issuer config", () => {
|
||||
expect(validateOIDCIssuerWellKnown(validWk)).toEqual({
|
||||
authorizationEndpoint: validWk.authorization_endpoint,
|
||||
tokenEndpoint: validWk.token_endpoint,
|
||||
registrationEndpoint: validWk.registration_endpoint,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return validated issuer config without registrationendpoint", () => {
|
||||
const wk = { ...validWk };
|
||||
delete wk.registration_endpoint;
|
||||
expect(validateOIDCIssuerWellKnown(wk)).toEqual({
|
||||
authorizationEndpoint: validWk.authorization_endpoint,
|
||||
tokenEndpoint: validWk.token_endpoint,
|
||||
registrationEndpoint: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
type TestCase = [string, any];
|
||||
it.each<TestCase>([
|
||||
["authorization_endpoint", undefined],
|
||||
["authorization_endpoint", { not: "a string" }],
|
||||
["token_endpoint", undefined],
|
||||
["token_endpoint", { not: "a string" }],
|
||||
["registration_endpoint", { not: "a string" }],
|
||||
["response_types_supported", undefined],
|
||||
["response_types_supported", "not an array"],
|
||||
["response_types_supported", ["doesnt include code"]],
|
||||
["grant_types_supported", undefined],
|
||||
["grant_types_supported", "not an array"],
|
||||
["grant_types_supported", ["doesnt include authorization_code"]],
|
||||
["code_challenge_methods_supported", undefined],
|
||||
["code_challenge_methods_supported", "not an array"],
|
||||
["code_challenge_methods_supported", ["doesnt include S256"]],
|
||||
])("should throw OP support error when %s is %s", (key, value) => {
|
||||
const wk = {
|
||||
...validWk,
|
||||
[key]: value,
|
||||
};
|
||||
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateIdToken()", () => {
|
||||
const nonce = "test-nonce";
|
||||
const issuer = "https://auth.org/issuer";
|
||||
const clientId = "test-client-id";
|
||||
const idToken = "test-id-token";
|
||||
|
||||
const validDecodedIdToken = {
|
||||
// nonce matches
|
||||
nonce,
|
||||
// not expired
|
||||
exp: Date.now() / 1000 + 5555,
|
||||
// audience is this client
|
||||
aud: clientId,
|
||||
// issuer matches
|
||||
iss: issuer,
|
||||
};
|
||||
beforeEach(() => {
|
||||
mocked(jwtDecode).mockClear().mockReturnValue(validDecodedIdToken);
|
||||
|
||||
jest.spyOn(logger, "error").mockClear();
|
||||
});
|
||||
|
||||
it("should throw when idToken is falsy", () => {
|
||||
expect(() => validateIdToken(undefined, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
});
|
||||
|
||||
it("should throw when idToken cannot be decoded", () => {
|
||||
mocked(jwtDecode).mockImplementation(() => {
|
||||
throw new Error("oh no!");
|
||||
});
|
||||
expect(() => validateIdToken(undefined, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
});
|
||||
|
||||
it("should throw when issuer does not match", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
iss: "https://badissuer.com",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid issuer"));
|
||||
});
|
||||
|
||||
it("should throw when audience does not include clientId", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
aud: "qwerty,uiop,asdf",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid audience"));
|
||||
});
|
||||
|
||||
it("should throw when audience includes clientId and other audiences", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
aud: `${clientId},uiop,asdf`,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid audience"));
|
||||
});
|
||||
|
||||
it("should throw when nonce does not match", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
nonce: "something else",
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid nonce"));
|
||||
});
|
||||
|
||||
it("should throw when token does not have an expiry", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
exp: undefined,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid expiry"));
|
||||
});
|
||||
|
||||
it("should throw when token is expired", () => {
|
||||
mocked(jwtDecode).mockReturnValue({
|
||||
...validDecodedIdToken,
|
||||
// expired in the past
|
||||
exp: Date.now() / 1000 - 777,
|
||||
});
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).toThrow(new Error(OidcError.InvalidIdToken));
|
||||
expect(logger.error).toHaveBeenCalledWith("Invalid ID token", new Error("Invalid expiry"));
|
||||
});
|
||||
|
||||
it("should not throw for a valid id token", () => {
|
||||
expect(() => validateIdToken(idToken, issuer, clientId, nonce)).not.toThrow();
|
||||
});
|
||||
});
|
||||
+294
-89
@@ -1,6 +1,7 @@
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { IActionsObject, PushProcessor } from "../../src/pushprocessor";
|
||||
import { EventType, IContent, MatrixClient, MatrixEvent } from "../../src";
|
||||
import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName, RuleId } from "../../src";
|
||||
import { mockClientMethodsUser } from "../test-utils/client";
|
||||
|
||||
describe("NotificationService", function () {
|
||||
const testUserId = "@ali:matrix.org";
|
||||
@@ -11,6 +12,23 @@ describe("NotificationService", function () {
|
||||
|
||||
let pushProcessor: PushProcessor;
|
||||
|
||||
const msc3914RoomCallRule = {
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "org.matrix.msc3401.call",
|
||||
},
|
||||
{
|
||||
kind: "call_started",
|
||||
},
|
||||
],
|
||||
actions: ["notify", { set_tweak: "sound", value: "default" }],
|
||||
};
|
||||
|
||||
// These would be better if individual rules were configured in the tests themselves.
|
||||
const matrixClient = {
|
||||
getRoom: function () {
|
||||
@@ -28,9 +46,8 @@ describe("NotificationService", function () {
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: {
|
||||
userId: testUserId,
|
||||
},
|
||||
...mockClientMethodsUser(testUserId),
|
||||
supportsIntentionalMentions: () => true,
|
||||
pushRules: {
|
||||
device: {},
|
||||
global: {
|
||||
@@ -80,51 +97,6 @@ describe("NotificationService", function () {
|
||||
pattern: "foo*bar",
|
||||
rule_id: "foobar",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "p[io]ng",
|
||||
rule_id: "pingpong",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "I ate [0-9] pies",
|
||||
rule_id: "pies",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "b[!ai]ke",
|
||||
rule_id: "bakebike",
|
||||
},
|
||||
],
|
||||
override: [
|
||||
{
|
||||
@@ -163,26 +135,11 @@ describe("NotificationService", function () {
|
||||
enabled: true,
|
||||
rule_id: ".m.rule.room_one_to_one",
|
||||
},
|
||||
{
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
default: true,
|
||||
enabled: true,
|
||||
conditions: [
|
||||
{
|
||||
kind: "event_match",
|
||||
key: "type",
|
||||
pattern: "org.matrix.msc3401.call",
|
||||
},
|
||||
{
|
||||
kind: "call_started",
|
||||
},
|
||||
],
|
||||
actions: ["notify", { set_tweak: "sound", value: "default" }],
|
||||
},
|
||||
],
|
||||
room: [],
|
||||
sender: [],
|
||||
underride: [
|
||||
msc3914RoomCallRule,
|
||||
{
|
||||
actions: ["dont-notify"],
|
||||
conditions: [
|
||||
@@ -287,30 +244,6 @@ describe("NotificationService", function () {
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
it("should bing on character group ([abc]) bing words.", function () {
|
||||
testEvent.event.content!.body = "Ping!";
|
||||
let actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
testEvent.event.content!.body = "Pong!";
|
||||
actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
it("should bing on character range ([a-z]) bing words.", function () {
|
||||
testEvent.event.content!.body = "I ate 6 pies";
|
||||
const actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
it("should bing on character negation ([!a]) bing words.", function () {
|
||||
testEvent.event.content!.body = "boke";
|
||||
let actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
testEvent.event.content!.body = "bake";
|
||||
actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(false);
|
||||
});
|
||||
|
||||
it("should not bing on room server ACL changes", function () {
|
||||
testEvent = utils.mkEvent({
|
||||
type: EventType.RoomServerAcl,
|
||||
@@ -330,6 +263,8 @@ describe("NotificationService", function () {
|
||||
// invalid
|
||||
|
||||
it("should gracefully handle bad input.", function () {
|
||||
// The following body is an object (not a string) and thus is invalid
|
||||
// for matching against.
|
||||
testEvent.event.content!.body = { foo: "bar" };
|
||||
const actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(false);
|
||||
@@ -493,4 +428,274 @@ describe("NotificationService", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test exact event matching", () => {
|
||||
it.each([
|
||||
// Simple string matching.
|
||||
{ value: "bar", eventValue: "bar", expected: true },
|
||||
// Matches are case-sensitive.
|
||||
{ value: "bar", eventValue: "BAR", expected: false },
|
||||
// Matches must match the full string.
|
||||
{ value: "bar", eventValue: "barbar", expected: false },
|
||||
// Values should not be type-coerced.
|
||||
{ value: "bar", eventValue: true, expected: false },
|
||||
{ value: "bar", eventValue: 1, expected: false },
|
||||
{ value: "bar", eventValue: false, expected: false },
|
||||
// Boolean matching.
|
||||
{ value: true, eventValue: true, expected: true },
|
||||
{ value: false, eventValue: false, expected: true },
|
||||
// Types should not be coerced.
|
||||
{ value: true, eventValue: "true", expected: false },
|
||||
{ value: true, eventValue: 1, expected: false },
|
||||
{ value: false, eventValue: null, expected: false },
|
||||
// Null matching.
|
||||
{ value: null, eventValue: null, expected: true },
|
||||
// Types should not be coerced
|
||||
{ value: null, eventValue: false, expected: false },
|
||||
{ value: null, eventValue: 0, expected: false },
|
||||
{ value: null, eventValue: "", expected: false },
|
||||
{ value: null, eventValue: undefined, expected: false },
|
||||
// Compound values should never be matched.
|
||||
{ value: "bar", eventValue: ["bar"], expected: false },
|
||||
{ value: "bar", eventValue: { bar: true }, expected: false },
|
||||
{ value: true, eventValue: [true], expected: false },
|
||||
{ value: true, eventValue: { true: true }, expected: false },
|
||||
{ value: null, eventValue: [], expected: false },
|
||||
{ value: null, eventValue: {}, expected: false },
|
||||
])("test $value against $eventValue", ({ value, eventValue, expected }) => {
|
||||
matrixClient.pushRules! = {
|
||||
global: {
|
||||
override: [
|
||||
{
|
||||
actions: [PushRuleActionName.Notify],
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventPropertyIs,
|
||||
key: "content.foo",
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
default: true,
|
||||
enabled: true,
|
||||
rule_id: ".m.rule.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
testEvent = utils.mkEvent({
|
||||
type: "m.room.message",
|
||||
room: testRoomId,
|
||||
user: "@alfred:localhost",
|
||||
event: true,
|
||||
content: {
|
||||
foo: eventValue,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(!!actions?.notify).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test event property contains", () => {
|
||||
it.each([
|
||||
// Simple string matching.
|
||||
{ value: "bar", eventValue: ["bar"], expected: true },
|
||||
// Matches are case-sensitive.
|
||||
{ value: "bar", eventValue: ["BAR"], expected: false },
|
||||
// Values should not be type-coerced.
|
||||
{ value: "bar", eventValue: [true], expected: false },
|
||||
{ value: "bar", eventValue: [1], expected: false },
|
||||
{ value: "bar", eventValue: [false], expected: false },
|
||||
// Boolean matching.
|
||||
{ value: true, eventValue: [true], expected: true },
|
||||
{ value: false, eventValue: [false], expected: true },
|
||||
// Types should not be coerced.
|
||||
{ value: true, eventValue: ["true"], expected: false },
|
||||
{ value: true, eventValue: [1], expected: false },
|
||||
{ value: false, eventValue: [null], expected: false },
|
||||
// Null matching.
|
||||
{ value: null, eventValue: [null], expected: true },
|
||||
// Types should not be coerced
|
||||
{ value: null, eventValue: [false], expected: false },
|
||||
{ value: null, eventValue: [0], expected: false },
|
||||
{ value: null, eventValue: [""], expected: false },
|
||||
{ value: null, eventValue: [undefined], expected: false },
|
||||
// Non-array or empty values should never be matched.
|
||||
{ value: "bar", eventValue: "bar", expected: false },
|
||||
{ value: "bar", eventValue: { bar: true }, expected: false },
|
||||
{ value: true, eventValue: { true: true }, expected: false },
|
||||
{ value: true, eventValue: true, expected: false },
|
||||
{ value: null, eventValue: [], expected: false },
|
||||
{ value: null, eventValue: {}, expected: false },
|
||||
{ value: null, eventValue: null, expected: false },
|
||||
{ value: null, eventValue: undefined, expected: false },
|
||||
])("test $value against $eventValue", ({ value, eventValue, expected }) => {
|
||||
matrixClient.pushRules! = {
|
||||
global: {
|
||||
override: [
|
||||
{
|
||||
actions: [PushRuleActionName.Notify],
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventPropertyContains,
|
||||
key: "content.foo",
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
default: true,
|
||||
enabled: true,
|
||||
rule_id: ".m.rule.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
testEvent = utils.mkEvent({
|
||||
type: "m.room.message",
|
||||
room: testRoomId,
|
||||
user: "@alfred:localhost",
|
||||
event: true,
|
||||
content: {
|
||||
foo: eventValue,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions?.notify).toBe(expected ? true : undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
// The properly escaped key works.
|
||||
{ key: "content.m\\.test.foo", pattern: "bar", expected: true },
|
||||
// An unescaped version does not match.
|
||||
{ key: "content.m.test.foo", pattern: "bar", expected: false },
|
||||
// Over escaping does not match.
|
||||
{ key: "content.m\\.test\\.foo", pattern: "bar", expected: false },
|
||||
// Escaping backslashes should match.
|
||||
{ key: "content.m\\\\example", pattern: "baz", expected: true },
|
||||
// An unnecessary escape sequence leaves the backslash and still matches.
|
||||
{ key: "content.m\\example", pattern: "baz", expected: true },
|
||||
])("test against escaped dotted paths '$key'", ({ key, pattern, expected }) => {
|
||||
testEvent = utils.mkEvent({
|
||||
type: "m.room.message",
|
||||
room: testRoomId,
|
||||
user: "@alfred:localhost",
|
||||
event: true,
|
||||
content: {
|
||||
// A dot in the field name.
|
||||
"m.test": { foo: "bar" },
|
||||
// A backslash in a field name.
|
||||
"m\\example": "baz",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
pushProcessor.ruleMatchesEvent(
|
||||
{
|
||||
rule_id: "rule1",
|
||||
actions: [],
|
||||
conditions: [
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: key,
|
||||
pattern: pattern,
|
||||
},
|
||||
],
|
||||
default: false,
|
||||
enabled: true,
|
||||
},
|
||||
testEvent,
|
||||
),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
describe("getPushRuleById()", () => {
|
||||
it("returns null when rule id is not in rule set", () => {
|
||||
expect(pushProcessor.getPushRuleById("non-existant-rule")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns push rule when it is found in rule set", () => {
|
||||
expect(pushProcessor.getPushRuleById(".org.matrix.msc3914.rule.room.call")).toEqual(msc3914RoomCallRule);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPushRuleAndKindById()", () => {
|
||||
it("returns null when rule id is not in rule set", () => {
|
||||
expect(pushProcessor.getPushRuleAndKindById("non-existant-rule")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns push rule when it is found in rule set", () => {
|
||||
expect(pushProcessor.getPushRuleAndKindById(".org.matrix.msc3914.rule.room.call")).toEqual({
|
||||
kind: "underride",
|
||||
rule: msc3914RoomCallRule,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("test intentional mentions behaviour", () => {
|
||||
it.each([RuleId.ContainsUserName, RuleId.ContainsDisplayName, RuleId.AtRoomNotification])(
|
||||
"Rule %s matches unless intentional mentions are enabled",
|
||||
(ruleId) => {
|
||||
const rule = {
|
||||
rule_id: ruleId,
|
||||
actions: [],
|
||||
conditions: [],
|
||||
default: false,
|
||||
enabled: true,
|
||||
};
|
||||
expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(true);
|
||||
|
||||
// Add the mentions property to the event and the rule is now disabled.
|
||||
testEvent = utils.mkEvent({
|
||||
type: "m.room.message",
|
||||
room: testRoomId,
|
||||
user: "@alfred:localhost",
|
||||
event: true,
|
||||
content: {
|
||||
"body": "",
|
||||
"msgtype": "m.text",
|
||||
"m.mentions": {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test PushProcessor.partsForDottedKey", function () {
|
||||
it.each([
|
||||
// A field with no dots.
|
||||
["m", ["m"]],
|
||||
// Simple dotted fields.
|
||||
["m.foo", ["m", "foo"]],
|
||||
["m.foo.bar", ["m", "foo", "bar"]],
|
||||
// Backslash is used as an escape character.
|
||||
["m\\.foo", ["m.foo"]],
|
||||
["m\\\\.foo", ["m\\", "foo"]],
|
||||
["m\\\\\\.foo", ["m\\.foo"]],
|
||||
["m\\\\\\\\.foo", ["m\\\\", "foo"]],
|
||||
["m\\foo", ["m\\foo"]],
|
||||
["m\\\\foo", ["m\\foo"]],
|
||||
["m\\\\\\foo", ["m\\\\foo"]],
|
||||
["m\\\\\\\\foo", ["m\\\\foo"]],
|
||||
// Ensure that escapes at the end don't cause issues.
|
||||
["m.foo\\", ["m", "foo\\"]],
|
||||
["m.foo\\\\", ["m", "foo\\"]],
|
||||
["m.foo\\.", ["m", "foo."]],
|
||||
["m.foo\\\\.", ["m", "foo\\", ""]],
|
||||
["m.foo\\\\\\.", ["m", "foo\\."]],
|
||||
// Empty parts (corresponding to properties which are an empty string) are allowed.
|
||||
[".m", ["", "m"]],
|
||||
["..m", ["", "", "m"]],
|
||||
["m.", ["m", ""]],
|
||||
["m..", ["m", "", ""]],
|
||||
["m..foo", ["m", "", "foo"]],
|
||||
])("partsFotDottedKey for %s", (path: string, expected: string[]) => {
|
||||
expect(PushProcessor.partsForDottedKey(path)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,9 @@ limitations under the License.
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
import { EventType } from "../../src/matrix";
|
||||
import { EventType, MatrixEvent, Room } from "../../src/matrix";
|
||||
import { synthesizeReceipt } from "../../src/models/read-receipt";
|
||||
import { encodeUri } from "../../src/utils";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -43,46 +42,46 @@ let httpBackend: MockHttpBackend;
|
||||
const THREAD_ID = "$thread_event_id";
|
||||
const ROOM_ID = "!123:matrix.org";
|
||||
|
||||
const threadEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: {
|
||||
"body": "Hello from a thread",
|
||||
"m.relates_to": {
|
||||
"event_id": THREAD_ID,
|
||||
"m.in_reply_to": {
|
||||
event_id: THREAD_ID,
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const roomEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: {
|
||||
body: "Hello from a room",
|
||||
},
|
||||
});
|
||||
|
||||
function mockServerSideSupport(client: MatrixClient, serverSideSupport: ServerSupport) {
|
||||
client.canSupport.set(Feature.ThreadUnreadNotifications, serverSideSupport);
|
||||
}
|
||||
|
||||
describe("Read receipt", () => {
|
||||
let threadEvent: MatrixEvent;
|
||||
let roomEvent: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
client = new MatrixClient({
|
||||
userId: "@user:server",
|
||||
baseUrl: "https://my.home.server",
|
||||
accessToken: "my.access.token",
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
});
|
||||
client.isGuest = () => false;
|
||||
client.supportsThreads = () => true;
|
||||
|
||||
threadEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: {
|
||||
"body": "Hello from a thread",
|
||||
"m.relates_to": {
|
||||
"event_id": THREAD_ID,
|
||||
"m.in_reply_to": {
|
||||
event_id: THREAD_ID,
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
roomEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: {
|
||||
body: "Hello from a room",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendReceipt", () => {
|
||||
@@ -101,7 +100,6 @@ describe("Read receipt", () => {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -123,7 +121,6 @@ describe("Read receipt", () => {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReadReceipt(threadEvent, ReceiptType.Read, true);
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -145,52 +142,62 @@ describe("Read receipt", () => {
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReceipt(roomEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends a room read receipt when there's no server support", async () => {
|
||||
it("should send a main timeline read receipt for a reaction to a thread root", async () => {
|
||||
roomEvent.event.event_id = THREAD_ID;
|
||||
const reaction = utils.mkReaction(roomEvent, client, client.getSafeUserId(), ROOM_ID);
|
||||
const thread = new Room(ROOM_ID, client, client.getSafeUserId()).createThread(
|
||||
THREAD_ID,
|
||||
roomEvent,
|
||||
[threadEvent],
|
||||
false,
|
||||
);
|
||||
threadEvent.setThread(thread);
|
||||
reaction.setThread(thread);
|
||||
|
||||
httpBackend
|
||||
.when(
|
||||
"POST",
|
||||
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
$roomId: ROOM_ID,
|
||||
$receiptType: ReceiptType.Read,
|
||||
$eventId: threadEvent.getId()!,
|
||||
$eventId: reaction.getId()!,
|
||||
}),
|
||||
)
|
||||
.check((request) => {
|
||||
expect(request.data.thread_id).toBeUndefined();
|
||||
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Unsupported);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, {});
|
||||
client.sendReceipt(reaction, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends a valid room read receipt even when body omitted", async () => {
|
||||
it("should always send unthreaded receipts if threads support is disabled", async () => {
|
||||
client.supportsThreads = () => false;
|
||||
|
||||
httpBackend
|
||||
.when(
|
||||
"POST",
|
||||
encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
$roomId: ROOM_ID,
|
||||
$receiptType: ReceiptType.Read,
|
||||
$eventId: threadEvent.getId()!,
|
||||
$eventId: roomEvent.getId()!,
|
||||
}),
|
||||
)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({});
|
||||
expect(request.data.thread_id).toEqual(undefined);
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Unsupported);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, undefined);
|
||||
client.sendReceipt(roomEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
@@ -199,9 +206,10 @@ describe("Read receipt", () => {
|
||||
|
||||
describe("synthesizeReceipt", () => {
|
||||
it.each([
|
||||
{ event: roomEvent, destinationId: MAIN_ROOM_TIMELINE },
|
||||
{ event: threadEvent, destinationId: threadEvent.threadRootId! },
|
||||
])("adds the receipt to $destinationId", ({ event, destinationId }) => {
|
||||
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
|
||||
{ getEvent: () => threadEvent, destinationId: THREAD_ID },
|
||||
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
|
||||
const event = getEvent();
|
||||
const userId = "@bob:example.org";
|
||||
const receiptType = ReceiptType.Read;
|
||||
|
||||
@@ -212,4 +220,42 @@ describe("Read receipt", () => {
|
||||
expect(content.thread_id).toEqual(destinationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addReceiptToStructure", () => {
|
||||
it("should not allow an older unthreaded receipt to clobber a `main` threaded one", () => {
|
||||
const userId = client.getSafeUserId();
|
||||
const room = new Room(ROOM_ID, client, userId);
|
||||
|
||||
const unthreadedReceipt: WrappedReceipt = {
|
||||
eventId: "$olderEvent",
|
||||
data: {
|
||||
ts: 1234567880,
|
||||
},
|
||||
};
|
||||
const mainTimelineReceipt: WrappedReceipt = {
|
||||
eventId: "$newerEvent",
|
||||
data: {
|
||||
ts: 1234567890,
|
||||
},
|
||||
};
|
||||
|
||||
room.addReceiptToStructure(
|
||||
mainTimelineReceipt.eventId,
|
||||
ReceiptType.ReadPrivate,
|
||||
userId,
|
||||
mainTimelineReceipt.data,
|
||||
false,
|
||||
);
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
|
||||
room.addReceiptToStructure(
|
||||
unthreadedReceipt.eventId,
|
||||
ReceiptType.ReadPrivate,
|
||||
userId,
|
||||
unthreadedReceipt.data,
|
||||
false,
|
||||
);
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
Copyright 2023 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 { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { ReceiptAccumulator } from "../../src/receipt-accumulator";
|
||||
|
||||
const roomId = "!foo:bar";
|
||||
|
||||
describe("ReceiptAccumulator", function () {
|
||||
/*
|
||||
* Note: at the time of writing, the ReceiptAccumulator uses the order of
|
||||
* events from sync as the determinant of which one is most recent. This
|
||||
* is correct, but inconsistent with other areas of the code, since we
|
||||
* don't persist this order, so in other places we are forced to use the
|
||||
* timestamp of events and hope that this matches up.
|
||||
*
|
||||
* The tests in this file provide ts values that are consistent with the
|
||||
* sync order, so they should still pass if we change to using ts to
|
||||
* determine order.
|
||||
*
|
||||
* Ideally, we would keep track of sync order so we can use it everywhere.
|
||||
* This would mean we are consistent with how the homeserver sees receipts
|
||||
* and notifications.
|
||||
*/
|
||||
|
||||
it("Discards previous unthreaded receipts for the same user", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2));
|
||||
});
|
||||
|
||||
it("Discards previous threaded receipts for the same user in the same thread", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1"));
|
||||
});
|
||||
|
||||
it("Collects multiple receipts for the same user if they are in different threads", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Collects multiple receipts for different users", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1");
|
||||
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
|
||||
["$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Collects last receipt for various users and threads", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Below, if ts=1, this receipt is going to be superceded by another
|
||||
// with ts=2
|
||||
const receipts = [
|
||||
newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1),
|
||||
newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 1, "thread1"),
|
||||
newReceipt("$event3", ReceiptType.Read, "@alice:localhost", 1, "thread1"),
|
||||
newReceipt("$event4", ReceiptType.Read, "@bobby:localhost", 1),
|
||||
newReceipt("$event5", ReceiptType.Read, "@bobby:localhost", 2),
|
||||
newReceipt("$event6", ReceiptType.Read, "@alice:localhost", 2),
|
||||
newReceipt("$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"),
|
||||
newReceipt("$event8", ReceiptType.Read, "@bobby:localhost", 1, "thread2"),
|
||||
newReceipt("$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"),
|
||||
newReceipt("$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"),
|
||||
newReceipt("$eventB", ReceiptType.Read, "@alice:localhost", 1, "thread2"),
|
||||
newReceipt("$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"),
|
||||
];
|
||||
|
||||
acc.consumeEphemeralEvents(receipts);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// Only the ts=2 receipts make it through
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event5", ReceiptType.Read, "@bobby:localhost", 2, undefined],
|
||||
["$event6", ReceiptType.Read, "@alice:localhost", 2, undefined],
|
||||
["$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
|
||||
["$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"],
|
||||
["$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"],
|
||||
["$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Keeps main thread receipts even when an unthreaded receipt came later", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Given receipts for the special thread "main" and also unthreaded
|
||||
// receipts (which have no thread id).
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "main");
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
|
||||
|
||||
// When we collect them
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// We preserve both: thread:main and unthreaded receipts are different
|
||||
// things, with different meanings.
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, "main"],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, undefined],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Keeps unthreaded receipts even when a main thread receipt came later", () => {
|
||||
const acc = new ReceiptAccumulator();
|
||||
|
||||
// Given receipts for the special thread "main" and also unthreaded
|
||||
// receipts (which have no thread id).
|
||||
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
|
||||
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "main");
|
||||
|
||||
// When we collect them
|
||||
acc.consumeEphemeralEvents([receipt1, receipt2]);
|
||||
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
|
||||
|
||||
// We preserve both: thread:main and unthreaded receipts are different
|
||||
// things, with different meanings.
|
||||
expect(newEvent).toEqual(
|
||||
newMultiReceipt([
|
||||
["$event1", ReceiptType.Read, "@alice:localhost", 1, undefined],
|
||||
["$event2", ReceiptType.Read, "@alice:localhost", 2, "main"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const newReceipt = (
|
||||
eventId: string,
|
||||
receiptType: ReceiptType,
|
||||
userId: string,
|
||||
ts: number,
|
||||
threadId: string | undefined = undefined,
|
||||
) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: roomId,
|
||||
content: {
|
||||
[eventId]: {
|
||||
[receiptType]: {
|
||||
[userId]: { ts, thread_id: threadId },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const newMultiReceipt = (infoArray: Array<[string, ReceiptType, string, number, string | undefined]>) => {
|
||||
return {
|
||||
type: "m.receipt",
|
||||
room_id: roomId,
|
||||
content: Object.fromEntries(
|
||||
infoArray.map(([eventId, receiptType, userId, ts, threadId]) => {
|
||||
return [
|
||||
eventId,
|
||||
{
|
||||
[receiptType]: {
|
||||
[userId]: { ts, thread_id: threadId },
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import "../../olm-loader";
|
||||
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
|
||||
@@ -24,7 +24,7 @@ function makeTransport(name: string) {
|
||||
return new DummyTransport<any, MSC3903ECDHPayload>(name, { type: "dummy" });
|
||||
}
|
||||
|
||||
describe("ECDHv1", function () {
|
||||
describe("ECDHv2", function () {
|
||||
beforeAll(async function () {
|
||||
await global.Olm.init();
|
||||
});
|
||||
@@ -37,9 +37,9 @@ describe("ECDHv1", function () {
|
||||
bobTransport.otherParty = aliceTransport;
|
||||
|
||||
// alice is signing in initiates and generates a code
|
||||
const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const alice = new MSC3903ECDHv2RendezvousChannel(aliceTransport);
|
||||
const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE);
|
||||
const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
const bob = new MSC3903ECDHv2RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
|
||||
const bobChecksum = await bob.connect();
|
||||
const aliceChecksum = await alice.connect();
|
||||
@@ -62,9 +62,9 @@ describe("ECDHv1", function () {
|
||||
bobTransport.otherParty = aliceTransport;
|
||||
|
||||
// alice is signing in initiates and generates a code
|
||||
const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const alice = new MSC3903ECDHv2RendezvousChannel(aliceTransport);
|
||||
const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE);
|
||||
const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
const bob = new MSC3903ECDHv2RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
|
||||
const bobChecksum = await bob.connect();
|
||||
const aliceChecksum = await alice.connect();
|
||||
@@ -87,16 +87,16 @@ describe("ECDHv1", function () {
|
||||
bobTransport.otherParty = aliceTransport;
|
||||
|
||||
// alice is signing in initiates and generates a code
|
||||
const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const alice = new MSC3903ECDHv2RendezvousChannel(aliceTransport);
|
||||
const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE);
|
||||
const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
const bob = new MSC3903ECDHv2RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
|
||||
const bobChecksum = await bob.connect();
|
||||
const aliceChecksum = await alice.connect();
|
||||
|
||||
expect(aliceChecksum).toEqual(bobChecksum);
|
||||
|
||||
expect(alice.connect()).rejects.toThrow();
|
||||
await expect(alice.connect()).rejects.toThrow();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await bob.cancel(RendezvousFailureReason.Unknown);
|
||||
@@ -109,9 +109,9 @@ describe("ECDHv1", function () {
|
||||
bobTransport.otherParty = aliceTransport;
|
||||
|
||||
// alice is signing in initiates and generates a code
|
||||
const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const alice = new MSC3903ECDHv2RendezvousChannel(aliceTransport);
|
||||
const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE);
|
||||
const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
const bob = new MSC3903ECDHv2RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
|
||||
const bobChecksum = await bob.connect();
|
||||
const aliceChecksum = await alice.connect();
|
||||
@@ -120,9 +120,9 @@ describe("ECDHv1", function () {
|
||||
|
||||
alice.close();
|
||||
|
||||
expect(alice.connect()).rejects.toThrow();
|
||||
expect(alice.send({})).rejects.toThrow();
|
||||
expect(alice.receive()).rejects.toThrow();
|
||||
await expect(alice.connect()).rejects.toThrow();
|
||||
await expect(alice.send({})).rejects.toThrow();
|
||||
await expect(alice.receive()).rejects.toThrow();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await bob.cancel(RendezvousFailureReason.Unknown);
|
||||
@@ -135,9 +135,9 @@ describe("ECDHv1", function () {
|
||||
bobTransport.otherParty = aliceTransport;
|
||||
|
||||
// alice is signing in initiates and generates a code
|
||||
const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const alice = new MSC3903ECDHv2RendezvousChannel(aliceTransport);
|
||||
const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE);
|
||||
const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
const bob = new MSC3903ECDHv2RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key));
|
||||
|
||||
const bobChecksum = await bob.connect();
|
||||
const aliceChecksum = await alice.connect();
|
||||
@@ -146,10 +146,10 @@ describe("ECDHv1", function () {
|
||||
|
||||
// send a message without encryption
|
||||
await aliceTransport.send({ iv: "dummy", ciphertext: "dummy" });
|
||||
expect(bob.receive()).rejects.toThrowError();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await bob.cancel(RendezvousFailureReason.Unknown);
|
||||
await expect(bob.receive()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("ciphertext before set up", async function () {
|
||||
@@ -159,14 +159,13 @@ describe("ECDHv1", function () {
|
||||
bobTransport.otherParty = aliceTransport;
|
||||
|
||||
// alice is signing in initiates and generates a code
|
||||
const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const alice = new MSC3903ECDHv2RendezvousChannel(aliceTransport);
|
||||
await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE);
|
||||
|
||||
await bobTransport.send({ iv: "dummy", ciphertext: "dummy" });
|
||||
|
||||
expect(alice.receive()).rejects.toThrowError();
|
||||
|
||||
await alice.cancel(RendezvousFailureReason.Unknown);
|
||||
await expect(alice.receive()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ import MockHttpBackend from "matrix-mock-request";
|
||||
import "../../olm-loader";
|
||||
import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import {
|
||||
ECDHv1RendezvousCode,
|
||||
ECDHv2RendezvousCode as ECDHRendezvousCode,
|
||||
MSC3903ECDHPayload,
|
||||
MSC3903ECDHv1RendezvousChannel,
|
||||
MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel,
|
||||
} from "../../../src/rendezvous/channels";
|
||||
import { MatrixClient } from "../../../src";
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ function makeMockClient(opts: {
|
||||
deviceId: string;
|
||||
deviceKey?: string;
|
||||
msc3882Enabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
msc3886Enabled: boolean;
|
||||
devices?: Record<string, Partial<DeviceInfo>>;
|
||||
verificationFunction?: (
|
||||
@@ -58,6 +59,17 @@ function makeMockClient(opts: {
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
capabilities: {
|
||||
"org.matrix.msc3882.get_login_token": {
|
||||
enabled: opts.msc3882Enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
@@ -111,6 +123,7 @@ describe("Rendezvous", function () {
|
||||
deviceId: "DEVICEID",
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: true,
|
||||
});
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
body: null,
|
||||
@@ -126,7 +139,7 @@ describe("Rendezvous", function () {
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
fetchFn,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
|
||||
expect(aliceRz.code).toBeUndefined();
|
||||
@@ -143,7 +156,7 @@ describe("Rendezvous", function () {
|
||||
const code = JSON.parse(aliceRz.code!) as RendezvousCode;
|
||||
|
||||
expect(code.intent).toEqual(RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE);
|
||||
expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256");
|
||||
expect(code.rendezvous?.algorithm).toEqual("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256");
|
||||
expect(code.rendezvous?.transport.type).toEqual("org.matrix.msc3886.http.v1");
|
||||
expect((code.rendezvous?.transport as MSC3886SimpleHttpRendezvousTransportDetails).uri).toEqual(
|
||||
"https://fallbackserver/rz/123",
|
||||
@@ -159,14 +172,20 @@ describe("Rendezvous", function () {
|
||||
|
||||
const cancelPromise = aliceRz.cancel(RendezvousFailureReason.UserDeclined);
|
||||
await httpBackend.flush("");
|
||||
expect(cancelPromise).resolves.toBeUndefined();
|
||||
await expect(cancelPromise).resolves.toBeUndefined();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
|
||||
await aliceRz.close();
|
||||
});
|
||||
|
||||
it("no protocols", async function () {
|
||||
async function testNoProtocols({
|
||||
msc3882Enabled,
|
||||
msc3882r0Only,
|
||||
}: {
|
||||
msc3882Enabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
}) {
|
||||
const aliceTransport = makeTransport("Alice");
|
||||
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
|
||||
transports.push(aliceTransport, bobTransport);
|
||||
@@ -178,14 +197,15 @@ describe("Rendezvous", function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: false,
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled,
|
||||
msc3882r0Only,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
aliceTransport.onCancelled = aliceOnFailure;
|
||||
await aliceRz.generateCode();
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode;
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode;
|
||||
|
||||
expect(code.rendezvous.key).toBeDefined();
|
||||
|
||||
@@ -193,7 +213,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
// bob is try to sign in and scans the code
|
||||
const bobOnFailure = jest.fn();
|
||||
const bobEcdh = new MSC3903ECDHv1RendezvousChannel(
|
||||
const bobEcdh = new MSC3903ECDHRendezvousChannel(
|
||||
bobTransport,
|
||||
decodeBase64(code.rendezvous.key), // alice's public key
|
||||
bobOnFailure,
|
||||
@@ -218,9 +238,17 @@ describe("Rendezvous", function () {
|
||||
|
||||
await aliceStartProm;
|
||||
await bobStartPromise;
|
||||
}
|
||||
|
||||
it("no protocols - r0", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: true });
|
||||
});
|
||||
|
||||
it("new device declines protocol", async function () {
|
||||
it("no protocols - r1", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: false });
|
||||
});
|
||||
|
||||
it("new device declines protocol with outcome unsupported", async function () {
|
||||
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
|
||||
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
|
||||
transports.push(aliceTransport, bobTransport);
|
||||
@@ -233,13 +261,14 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
aliceTransport.onCancelled = aliceOnFailure;
|
||||
await aliceRz.generateCode();
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode;
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode;
|
||||
|
||||
expect(code.rendezvous.key).toBeDefined();
|
||||
|
||||
@@ -247,7 +276,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
// bob is try to sign in and scans the code
|
||||
const bobOnFailure = jest.fn();
|
||||
const bobEcdh = new MSC3903ECDHv1RendezvousChannel(
|
||||
const bobEcdh = new MSC3903ECDHRendezvousChannel(
|
||||
bobTransport,
|
||||
decodeBase64(code.rendezvous.key), // alice's public key
|
||||
bobOnFailure,
|
||||
@@ -278,7 +307,7 @@ describe("Rendezvous", function () {
|
||||
expect(aliceOnFailure).toHaveBeenCalledWith(RendezvousFailureReason.UnsupportedAlgorithm);
|
||||
});
|
||||
|
||||
it("new device declines protocol", async function () {
|
||||
it("new device requests an invalid protocol", async function () {
|
||||
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
|
||||
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
|
||||
transports.push(aliceTransport, bobTransport);
|
||||
@@ -291,13 +320,14 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
aliceTransport.onCancelled = aliceOnFailure;
|
||||
await aliceRz.generateCode();
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode;
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode;
|
||||
|
||||
expect(code.rendezvous.key).toBeDefined();
|
||||
|
||||
@@ -305,7 +335,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
// bob is try to sign in and scans the code
|
||||
const bobOnFailure = jest.fn();
|
||||
const bobEcdh = new MSC3903ECDHv1RendezvousChannel(
|
||||
const bobEcdh = new MSC3903ECDHRendezvousChannel(
|
||||
bobTransport,
|
||||
decodeBase64(code.rendezvous.key), // alice's public key
|
||||
bobOnFailure,
|
||||
@@ -349,13 +379,14 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
aliceTransport.onCancelled = aliceOnFailure;
|
||||
await aliceRz.generateCode();
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode;
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode;
|
||||
|
||||
expect(code.rendezvous.key).toBeDefined();
|
||||
|
||||
@@ -363,7 +394,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
// bob is try to sign in and scans the code
|
||||
const bobOnFailure = jest.fn();
|
||||
const bobEcdh = new MSC3903ECDHv1RendezvousChannel(
|
||||
const bobEcdh = new MSC3903ECDHRendezvousChannel(
|
||||
bobTransport,
|
||||
decodeBase64(code.rendezvous.key), // alice's public key
|
||||
bobOnFailure,
|
||||
@@ -409,13 +440,14 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
aliceTransport.onCancelled = aliceOnFailure;
|
||||
await aliceRz.generateCode();
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode;
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode;
|
||||
|
||||
expect(code.rendezvous.key).toBeDefined();
|
||||
|
||||
@@ -423,7 +455,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
// bob is try to sign in and scans the code
|
||||
const bobOnFailure = jest.fn();
|
||||
const bobEcdh = new MSC3903ECDHv1RendezvousChannel(
|
||||
const bobEcdh = new MSC3903ECDHRendezvousChannel(
|
||||
bobTransport,
|
||||
decodeBase64(code.rendezvous.key), // alice's public key
|
||||
bobOnFailure,
|
||||
@@ -477,6 +509,7 @@ describe("Rendezvous", function () {
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
devices,
|
||||
deviceKey: "aaaa",
|
||||
@@ -485,11 +518,11 @@ describe("Rendezvous", function () {
|
||||
master: "mmmmm",
|
||||
},
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHv1RendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
|
||||
aliceTransport.onCancelled = aliceOnFailure;
|
||||
await aliceRz.generateCode();
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHv1RendezvousCode;
|
||||
const code = JSON.parse(aliceRz.code!) as ECDHRendezvousCode;
|
||||
|
||||
expect(code.rendezvous.key).toBeDefined();
|
||||
|
||||
@@ -497,7 +530,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
// bob is try to sign in and scans the code
|
||||
const bobOnFailure = jest.fn();
|
||||
const bobEcdh = new MSC3903ECDHv1RendezvousChannel(
|
||||
const bobEcdh = new MSC3903ECDHRendezvousChannel(
|
||||
bobTransport,
|
||||
decodeBase64(code.rendezvous.key), // alice's public key
|
||||
bobOnFailure,
|
||||
@@ -570,7 +603,7 @@ describe("Rendezvous", function () {
|
||||
|
||||
it("device not online within timeout", async function () {
|
||||
const { aliceRz } = await completeLogin({});
|
||||
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError();
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("device appears online within timeout", async function () {
|
||||
@@ -594,7 +627,7 @@ describe("Rendezvous", function () {
|
||||
getFingerprint: () => "bbbb",
|
||||
};
|
||||
}, 1500);
|
||||
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError();
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("mismatched device key", async function () {
|
||||
@@ -603,6 +636,6 @@ describe("Rendezvous", function () {
|
||||
getFingerprint: () => "XXXX",
|
||||
},
|
||||
});
|
||||
expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrowError(/different key/);
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,10 +95,10 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
}
|
||||
}
|
||||
it("should throw an error when no server available", function () {
|
||||
it("should throw an error when no server available", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false });
|
||||
const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({ client, fetchFn });
|
||||
expect(simpleHttpTransport.send({})).rejects.toThrowError("Invalid rendezvous URI");
|
||||
await expect(simpleHttpTransport.send({})).rejects.toThrow("Invalid rendezvous URI");
|
||||
});
|
||||
|
||||
it("POST to fallback server", async function () {
|
||||
@@ -130,7 +130,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fetchFn,
|
||||
});
|
||||
const prom = simpleHttpTransport.send({});
|
||||
expect(prom).rejects.toThrowError();
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
body: null,
|
||||
response: {
|
||||
@@ -138,7 +137,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
await httpBackend.flush("");
|
||||
await Promise.all([expect(prom).rejects.toThrow(), httpBackend.flush("")]);
|
||||
});
|
||||
|
||||
it("POST with absolute path response", async function () {
|
||||
@@ -163,15 +162,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
);
|
||||
});
|
||||
|
||||
it("POST with relative path response including parent", async function () {
|
||||
await postAndCheckLocation(
|
||||
false,
|
||||
"https://fallbackserver/rz/abc",
|
||||
"../xyz/123",
|
||||
"https://fallbackserver/rz/xyz/123",
|
||||
);
|
||||
});
|
||||
|
||||
it("POST to follow 307 to other server", async function () {
|
||||
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc3886Enabled: false });
|
||||
const simpleHttpTransport = new MSC3886SimpleHttpRendezvousTransport({
|
||||
@@ -373,7 +363,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
fetchFn,
|
||||
});
|
||||
expect(simpleHttpTransport.details()).rejects.toThrowError();
|
||||
await expect(simpleHttpTransport.details()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("send after cancelled", async function () {
|
||||
@@ -384,7 +374,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fetchFn,
|
||||
});
|
||||
await simpleHttpTransport.cancel(RendezvousFailureReason.UserDeclined);
|
||||
expect(simpleHttpTransport.send({})).resolves.toBeUndefined();
|
||||
await expect(simpleHttpTransport.send({})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("receive before ready", async function () {
|
||||
@@ -394,7 +384,7 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
fallbackRzServer: "https://fallbackserver/rz",
|
||||
fetchFn,
|
||||
});
|
||||
expect(simpleHttpTransport.receive()).rejects.toThrowError();
|
||||
await expect(simpleHttpTransport.receive()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("404 failure callback", async function () {
|
||||
@@ -407,7 +397,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
onFailure,
|
||||
});
|
||||
|
||||
expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined();
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
body: null,
|
||||
response: {
|
||||
@@ -415,8 +404,11 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
await httpBackend.flush("", 1);
|
||||
expect(onFailure).toBeCalledWith(RendezvousFailureReason.Unknown);
|
||||
await Promise.all([
|
||||
expect(simpleHttpTransport.send({ foo: "baa" })).resolves.toBeUndefined(),
|
||||
httpBackend.flush("", 1),
|
||||
]);
|
||||
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Unknown);
|
||||
});
|
||||
|
||||
it("404 failure callback mapped to expired", async function () {
|
||||
@@ -447,7 +439,6 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
}
|
||||
{
|
||||
// GET with 404 to simulate expiry
|
||||
expect(simpleHttpTransport.receive()).resolves.toBeUndefined();
|
||||
httpBackend.when("GET", "https://fallbackserver/rz/123").response = {
|
||||
body: { foo: "baa" },
|
||||
response: {
|
||||
@@ -455,8 +446,8 @@ describe("SimpleHttpRendezvousTransport", function () {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
await httpBackend.flush("");
|
||||
expect(onFailure).toBeCalledWith(RendezvousFailureReason.Expired);
|
||||
await Promise.all([expect(simpleHttpTransport.receive()).resolves.toBeUndefined(), httpBackend.flush("")]);
|
||||
expect(onFailure).toHaveBeenCalledWith(RendezvousFailureReason.Expired);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright 2023 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 fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { EventType, MatrixClient, Room } from "../../src";
|
||||
import { RoomHierarchy } from "../../src/room-hierarchy";
|
||||
|
||||
describe("RoomHierarchy", () => {
|
||||
const roomId = "!room:server";
|
||||
const client = new MatrixClient({ baseUrl: "https://server", userId: "@user:server" });
|
||||
|
||||
it("should load data from /hierarchy API", async () => {
|
||||
const spy = fetchMock.getOnce(
|
||||
`https://server/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/hierarchy?suggested_only=false`,
|
||||
{
|
||||
rooms: [],
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const room = new Room(roomId, client, client.getSafeUserId());
|
||||
const hierarchy = new RoomHierarchy(room);
|
||||
const res = await hierarchy.load();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(res).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("itSuggested", () => {
|
||||
it("should return true if a room is suggested", async () => {
|
||||
const spy = fetchMock.getOnce(
|
||||
`https://server/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/hierarchy?suggested_only=false`,
|
||||
{
|
||||
rooms: [
|
||||
{
|
||||
children_state: [
|
||||
{
|
||||
origin_server_ts: 111,
|
||||
content: {
|
||||
suggested: true,
|
||||
via: ["matrix.org"],
|
||||
},
|
||||
type: EventType.SpaceChild,
|
||||
state_key: "!child_room:server",
|
||||
},
|
||||
],
|
||||
room_id: roomId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const room = new Room(roomId, client, client.getSafeUserId());
|
||||
const hierarchy = new RoomHierarchy(room);
|
||||
await hierarchy.load();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(hierarchy.isSuggested(hierarchy.root.roomId, "!child_room:server")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
|
||||
import { EventType, RoomState } from "../../src";
|
||||
import {
|
||||
createClient,
|
||||
EventType,
|
||||
MatrixClient,
|
||||
RoomState,
|
||||
UNSTABLE_MSC2666_MUTUAL_ROOMS,
|
||||
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
|
||||
UNSTABLE_MSC2666_SHARED_ROOMS,
|
||||
} from "../../src";
|
||||
|
||||
describe("RoomMember", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -481,3 +491,125 @@ describe("RoomMember", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MutualRooms", () => {
|
||||
let client: MatrixClient;
|
||||
const HS_URL = "https://example.com";
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
const QUERIED_USER = "@user:example.com";
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = true;
|
||||
|
||||
client = createClient({
|
||||
baseUrl: HS_URL,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
function enableFeature(feature: string) {
|
||||
const mapping: Record<string, boolean> = {};
|
||||
|
||||
mapping[feature] = true;
|
||||
|
||||
fetchMock.get(`${HS_URL}/_matrix/client/versions`, {
|
||||
unstable_features: mapping,
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
}
|
||||
|
||||
it("supports the initial MSC version (shared rooms)", async () => {
|
||||
enableFeature(UNSTABLE_MSC2666_SHARED_ROOMS);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/shared_rooms/:user_id", (rawUrl) => {
|
||||
const segments = rawUrl.split("/");
|
||||
const lastSegment = decodeURIComponent(segments[segments.length - 1]);
|
||||
|
||||
expect(lastSegment).toEqual(QUERIED_USER);
|
||||
|
||||
return {
|
||||
joined: ["!test:example.com"],
|
||||
};
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!test:example.com"]);
|
||||
});
|
||||
|
||||
it("supports the renaming MSC version (mutual rooms)", async () => {
|
||||
enableFeature(UNSTABLE_MSC2666_MUTUAL_ROOMS);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms/:user_id", (rawUrl) => {
|
||||
const segments = rawUrl.split("/");
|
||||
const lastSegment = decodeURIComponent(segments[segments.length - 1]);
|
||||
|
||||
expect(lastSegment).toEqual(QUERIED_USER);
|
||||
|
||||
return {
|
||||
joined: ["!test2:example.com"],
|
||||
};
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!test2:example.com"]);
|
||||
});
|
||||
|
||||
describe("can work the latest MSC version (query mutual rooms)", () => {
|
||||
beforeEach(() => {
|
||||
enableFeature(UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS);
|
||||
});
|
||||
|
||||
it("works with a simple response", async () => {
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms", (rawUrl) => {
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
expect(url.searchParams.get("user_id")).toEqual(QUERIED_USER);
|
||||
|
||||
return {
|
||||
joined: ["!test3:example.com"],
|
||||
};
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!test3:example.com"]);
|
||||
});
|
||||
|
||||
it("works with a paginated response", async () => {
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms", (rawUrl) => {
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
expect(url.searchParams.get("user_id")).toEqual(QUERIED_USER);
|
||||
|
||||
const token = url.searchParams.get("batch_token");
|
||||
|
||||
if (token == "yahaha") {
|
||||
return {
|
||||
joined: ["!korok:example.com"],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
joined: ["!rock:example.com"],
|
||||
next_batch_token: "yahaha",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!rock:example.com", "!korok:example.com"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { DecryptionError } from "../../src/crypto/algorithms";
|
||||
import { defer } from "../../src/utils";
|
||||
|
||||
describe("RoomState", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -886,7 +888,7 @@ describe("RoomState", function () {
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds locations to beacons", () => {
|
||||
it("adds locations to beacons", async () => {
|
||||
const location1 = makeBeaconEvent(userA, {
|
||||
beaconInfoId: "$beacon1",
|
||||
timestamp: Date.now() + 1,
|
||||
@@ -906,7 +908,7 @@ describe("RoomState", function () {
|
||||
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
|
||||
const addLocationsSpy = jest.spyOn(beaconInstance, "addLocations");
|
||||
|
||||
state.processBeaconEvents([location1, location2, location3], mockClient);
|
||||
await state.processBeaconEvents([location1, location2, location3], mockClient);
|
||||
|
||||
expect(addLocationsSpy).toHaveBeenCalledTimes(2);
|
||||
// only called with locations for beacon1
|
||||
@@ -978,51 +980,50 @@ describe("RoomState", function () {
|
||||
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decrypts related events if needed", () => {
|
||||
it("decrypts related events if needed", async () => {
|
||||
const location = makeBeaconEvent(userA, {
|
||||
beaconInfoId: beacon1.getId(),
|
||||
});
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
|
||||
await state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
|
||||
// discard unrelated events early
|
||||
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("listens for decryption on events that are being decrypted", () => {
|
||||
it("awaits for decryption on events that are being decrypted", async () => {
|
||||
const decryptingRelatedEvent = new MatrixEvent({
|
||||
sender: userA,
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
content: beacon1RelationContent,
|
||||
});
|
||||
jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true);
|
||||
// spy on event.once
|
||||
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once");
|
||||
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
await state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
|
||||
// listener was added
|
||||
expect(eventOnceSpy).toHaveBeenCalled();
|
||||
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("listens for decryption on events that have decryption failure", () => {
|
||||
it("listens for decryption on events that have decryption failure", async () => {
|
||||
const failedDecryptionRelatedEvent = new MatrixEvent({
|
||||
sender: userA,
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
content: beacon1RelationContent,
|
||||
});
|
||||
jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||
mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg"));
|
||||
// spy on event.once
|
||||
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once");
|
||||
const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once");
|
||||
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
await state.processBeaconEvents([failedDecryptionRelatedEvent], mockClient);
|
||||
|
||||
// listener was added
|
||||
expect(eventOnceSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discard events that are not m.beacon type after decryption", () => {
|
||||
it("discard events that are not m.beacon type after decryption", async () => {
|
||||
const decryptingRelatedEvent = new MatrixEvent({
|
||||
sender: userA,
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
@@ -1032,7 +1033,7 @@ describe("RoomState", function () {
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
|
||||
const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear();
|
||||
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
await state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
|
||||
// this event is a message after decryption
|
||||
decryptingRelatedEvent.event.type = EventType.RoomMessage;
|
||||
@@ -1041,7 +1042,7 @@ describe("RoomState", function () {
|
||||
expect(addLocationsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds locations to beacons after decryption", () => {
|
||||
it("adds locations to beacons after decryption", async () => {
|
||||
const decryptingRelatedEvent = new MatrixEvent({
|
||||
sender: userA,
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
@@ -1051,16 +1052,20 @@ describe("RoomState", function () {
|
||||
beaconInfoId: "$beacon1",
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true);
|
||||
|
||||
const deferred = defer<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
|
||||
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
|
||||
const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear();
|
||||
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
const prom = state.processBeaconEvents([decryptingRelatedEvent], mockClient);
|
||||
|
||||
// update type after '''decryption'''
|
||||
decryptingRelatedEvent.event.type = M_BEACON.name;
|
||||
decryptingRelatedEvent.event.content = locationEvent.event.content;
|
||||
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
|
||||
deferred.resolve();
|
||||
await prom;
|
||||
|
||||
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
|
||||
});
|
||||
|
||||
+667
-278
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user