Compare commits
360 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f63b47134 | |||
| c89f220e52 | |||
| 9675a1584d | |||
| c81199b9d5 | |||
| 6bdb087883 | |||
| c4f00895b1 | |||
| f8c3973efd | |||
| 0c0775c0bf | |||
| 70edf0f34d | |||
| b46b31563e | |||
| 8007bc5fe8 | |||
| d178fbf9cd | |||
| f81036346f | |||
| 1a364c93c3 | |||
| 1cd6fe7775 | |||
| 89d0133c61 | |||
| a8b3369dd0 | |||
| 99600e87f1 | |||
| 7cf59d64e6 | |||
| 5967c670d8 | |||
| 2fe35fed13 | |||
| 2d1308c733 | |||
| 11348f9532 | |||
| 869576747c | |||
| 35ea144bca | |||
| 5bf29ef543 | |||
| 99b3cf2279 | |||
| 5e2acb558b | |||
| 19494e093b | |||
| ab217bdc35 | |||
| c4d32a3292 | |||
| 8e01b654bc | |||
| dc406ee2e8 | |||
| be8b769542 | |||
| 5973a15f68 | |||
| 3c28cfc96a | |||
| c99378501b | |||
| b10a804a03 | |||
| 2337d5a7af | |||
| 5a49ed4ebb | |||
| 22db9eb245 | |||
| 4cddc7397d | |||
| 418b69914a | |||
| 0082964345 | |||
| 96b3c79566 | |||
| 41a6f18125 | |||
| a2b2e8dbdf | |||
| abd920f0f4 | |||
| 5333d0e0ba | |||
| c885542628 | |||
| 81b58388ee | |||
| 0d486eaade | |||
| 76b9c3950b | |||
| 6176cb6d7b | |||
| b9a107f9ff | |||
| 06e8cea63d | |||
| 815c36e075 | |||
| d355073d10 | |||
| 2ef3ebb466 | |||
| 92f7481fdd | |||
| 8df30ed068 | |||
| 49624d5d73 | |||
| 8e5128ad3c | |||
| 8ac2f2a78d | |||
| 630440c59c | |||
| 6932437360 | |||
| f381dfe991 | |||
| 6a98b835a8 | |||
| ae01c0915c | |||
| a597a9d660 | |||
| 9661cdecf2 | |||
| 533070c603 | |||
| eae1c2d48b | |||
| 070a89d89d | |||
| ffc9fb34d0 | |||
| d030c83cee | |||
| c115e055c6 | |||
| 0f65088fd9 | |||
| a1ff63adcb | |||
| 31fc5f23be | |||
| febef3fc7c | |||
| f2625348d8 | |||
| 6d1d04782a | |||
| 5e67a173c8 | |||
| 9780643ce7 | |||
| 608c6ece56 | |||
| 3a55efb476 | |||
| 48d4f1b0cc | |||
| a80e90b42d | |||
| 2c13e133b7 | |||
| 68898aeff2 | |||
| f604ab2f63 | |||
| b7d45e83f8 | |||
| db0e3cfbb0 | |||
| 87b90cc983 | |||
| cc9545e313 | |||
| ed2792e6d8 | |||
| dd53ec722f | |||
| b03dc6ac43 | |||
| 13c7e0ebda | |||
| 2cd63ca4b9 | |||
| 479c4278a6 | |||
| 636fc3daaa | |||
| 1d1309870a | |||
| 13b8f01062 | |||
| cd672ec4cf | |||
| 2363703b64 | |||
| 1250bb8833 | |||
| 016ef12c4a | |||
| 84d193a9a2 | |||
| 9d5f1bb4fc | |||
| 228131edf3 | |||
| 23ad637aad | |||
| 103617c70e | |||
| 8d84621b07 | |||
| 6d018826f4 | |||
| 41878c7a43 | |||
| f31e83fd03 | |||
| b515cdbdbb | |||
| f4b6f91ee2 | |||
| df4536492c | |||
| 2e98da4224 | |||
| 48d9d9b4c9 | |||
| d90ae11e2b | |||
| 3f246c6080 | |||
| 68911520d3 | |||
| 393a8d0cdb | |||
| 51b63092b4 | |||
| b49c9639b9 | |||
| c588611fc0 | |||
| 5b34e4beaf | |||
| 91f16e5e8e | |||
| 9cf257da0e | |||
| 188de3c4c8 | |||
| 67019a3486 | |||
| a39b1203f2 | |||
| df1a6a583a | |||
| c49a527e5e | |||
| a7496627fc | |||
| 8ef2ca681c | |||
| 0c7342cb20 | |||
| 429c05ba85 | |||
| af9993a710 | |||
| ff501834e6 | |||
| ef9157b37a | |||
| da0a55cea4 | |||
| d644f111ea | |||
| b2018ef81b | |||
| a4faab6155 | |||
| 4ab226e580 | |||
| 1889f8dad5 | |||
| e98ce78027 | |||
| 83ba0fbb49 | |||
| 757c5e1d71 | |||
| eca651c0c2 | |||
| 2205445a50 | |||
| f168144c84 | |||
| eb288d125f | |||
| 4a72364fe3 | |||
| c2fa579fb2 | |||
| f71735d0c2 | |||
| e5ccfa86fe | |||
| 97c531aa42 | |||
| 44487078fb | |||
| e3c70a3ee4 | |||
| feb60a54b2 | |||
| c6e6248cd6 | |||
| 10cd84a653 | |||
| c36166d156 | |||
| 3a2cf14a68 | |||
| dd94f67a4f | |||
| 138281c620 | |||
| f75abecc92 | |||
| 378a91fe10 | |||
| 300635e3ee | |||
| 37ba169abf | |||
| e6e7798389 | |||
| 48fe267ea7 | |||
| a11fd8bc86 | |||
| eb9e557a64 | |||
| 41c8c40d47 | |||
| b9e684fdc3 | |||
| 9faff0dbff | |||
| 9044145a7e | |||
| 939def2aa1 | |||
| c54f8f6106 | |||
| 25a777a0a6 | |||
| 7de9b23e59 | |||
| d179b8c557 | |||
| 76f993e7ff | |||
| 430e6cae94 | |||
| e01a1d533c | |||
| 46a6a76a41 | |||
| d2e951738a | |||
| 882dc920c3 | |||
| 9efc0acb9d | |||
| 625753c388 | |||
| a28530004a | |||
| 437b7ff780 | |||
| 24ed030294 | |||
| 5c160d0f45 | |||
| 53615c9938 | |||
| d8735cf543 | |||
| ffb4cae792 | |||
| 0261868eb6 | |||
| 6ba4b35526 | |||
| f5ad4d0a73 | |||
| 582ea68c31 | |||
| 304c2b12bf | |||
| a3762c8e22 | |||
| 8b2a334ac4 | |||
| 5931a5119c | |||
| 6ae3c208f6 | |||
| 107e28e114 | |||
| 1d1157f546 | |||
| fe3f969698 | |||
| 96c6c99644 | |||
| 55230dd0ea | |||
| 7813e12eb0 | |||
| 036fd943ac | |||
| 84bd8ab81f | |||
| a25ba7bfd9 | |||
| 311494bd44 | |||
| 89b7e7d792 | |||
| 7921fee164 | |||
| 5bc132a24c | |||
| 685ef791c8 | |||
| 4458dcc2a4 | |||
| 36c958642c | |||
| b62e97eb92 | |||
| 448fab9e8a | |||
| e2a2039aa8 | |||
| 99f70cd048 | |||
| bf81c4bfeb | |||
| 370dd6a0eb | |||
| f760ece8b4 | |||
| 93e339affe | |||
| 5707b48fd2 | |||
| 8ac918c10f | |||
| 1cd8bed705 | |||
| e0dacf7529 | |||
| 29d9bdac61 | |||
| 88d066a10c | |||
| ce7b7bf44f | |||
| 07a9eb3c96 | |||
| f8f22a3edd | |||
| 084beaa947 | |||
| 73a87652fe | |||
| 4a4b454f27 | |||
| 6f82f08c7b | |||
| c41949de15 | |||
| f941fd896e | |||
| d750e33ec9 | |||
| a370442328 | |||
| bddf2b9682 | |||
| 74a2e694c3 | |||
| 748d03ba11 | |||
| 2f3f0b340e | |||
| 12e479a93e | |||
| 6e2ac03f7e | |||
| 6359e10bcf | |||
| b3a2b8b8c4 | |||
| 30a9119e31 | |||
| 7a52dba86c | |||
| d6177cdfc9 | |||
| c4f3fd3289 | |||
| 31f38550e3 | |||
| 0643f38592 | |||
| c0264954ed | |||
| 7501e28dec | |||
| febc4c9ad6 | |||
| 6b1d53cc14 | |||
| 04fcd5880b | |||
| 4bcea2cead | |||
| 6468d79458 | |||
| a871376350 | |||
| 6beb693616 | |||
| 11661bbc8d | |||
| 2d57f28d5a | |||
| c52f857599 | |||
| 5d016c1e4f | |||
| 9f04c0555c | |||
| 9293986e3b | |||
| 8426d8cae1 | |||
| 3baf6ec2c6 | |||
| 38cd6f93e6 | |||
| a3a6742c67 | |||
| 4ce837b20e | |||
| 884bd2585a | |||
| c306d87f80 | |||
| b94d137398 | |||
| 5595e8497f | |||
| 5d233f3863 | |||
| 0f4fa5ad51 | |||
| 1de6de05a1 | |||
| c8f8fb587d | |||
| 2f79e6c056 | |||
| 42be793a56 | |||
| 7c2a12085c | |||
| 3cf6f568f3 | |||
| 4db08cb78e | |||
| 25e5d79cf6 | |||
| 6c8e3d0707 | |||
| 3139f5729b | |||
| bb8a894105 | |||
| 223dfffdfb | |||
| f19f0a8793 | |||
| a5224c1820 | |||
| 513201b9c1 | |||
| 02ca5c78cf | |||
| af63d9bd05 | |||
| 95baccfbc1 | |||
| 10b6c2463d | |||
| 6e8d15e5ed | |||
| 2e4276437a | |||
| 6a761af867 | |||
| 53a72df01b | |||
| 75e710d93e | |||
| 1457ab0cf4 | |||
| 14aafb7977 | |||
| 90d00b863f | |||
| 5f0ada9578 | |||
| f01037fe0d | |||
| 2cda6655d7 | |||
| 6eec2ceeeb | |||
| 68317ac836 | |||
| 5c45c980e9 | |||
| 66251e0855 | |||
| ff53557957 | |||
| 126352afd5 | |||
| f33da83d90 | |||
| 74193ad057 | |||
| c672cad1a1 | |||
| d59bb240fa | |||
| 65d988734e | |||
| 4a402f0bd7 | |||
| 9fed45e47c | |||
| fe67a68c95 | |||
| 4d3d4028a0 | |||
| 8f901590ff | |||
| d29b8520f7 | |||
| 37e1fd9af5 | |||
| 76dbc7500f | |||
| 3664f8c3c2 | |||
| d0a10497bb | |||
| 6385c9c0da | |||
| cecac3152e | |||
| f6c99b1d25 | |||
| 407ec4d67a | |||
| 4947a0cb64 | |||
| f134d6db01 | |||
| fde6cebc20 | |||
| 425cf6b91e | |||
| a3e273d6f1 | |||
| b1a3b264e5 | |||
| 053643a8ba | |||
| d2ea149012 | |||
| 23d244520c | |||
| 267b52099b | |||
| 430fd5660a |
+11
-4
@@ -1,8 +1,15 @@
|
||||
* @matrix-org/element-web
|
||||
/.github/workflows/** @matrix-org/element-web-app-team
|
||||
/package.json @matrix-org/element-web-app-team
|
||||
/yarn.lock @matrix-org/element-web-app-team
|
||||
* @matrix-org/element-web-reviewers
|
||||
/.github/workflows/** @matrix-org/element-web-team
|
||||
/package.json @matrix-org/element-web-team
|
||||
/yarn.lock @matrix-org/element-web-team
|
||||
/src/webrtc @matrix-org/element-call-reviewers
|
||||
/src/matrixrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/webrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/matrixrtc @matrix-org/element-call-reviewers
|
||||
|
||||
/src/crypto @matrix-org/element-crypto-web-reviewers
|
||||
/src/rust-crypto @matrix-org/element-crypto-web-reviewers
|
||||
/spec/integ/crypto @matrix-org/element-crypto-web-reviewers
|
||||
/spec/unit/crypto.spec.ts @matrix-org/element-crypto-web-reviewers
|
||||
/spec/unit/crypto @matrix-org/element-crypto-web-reviewers
|
||||
/spec/unit/rust-crypto @matrix-org/element-crypto-web-reviewers
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Sign Release Tarball
|
||||
description: Generates signature for release tarball and uploads it as a release asset
|
||||
inputs:
|
||||
gpg-fingerprint:
|
||||
description: Fingerprint of the GPG key to use for signing the tarball.
|
||||
required: true
|
||||
upload-url:
|
||||
description: GitHub release upload URL to upload the signature file to.
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Generate tarball signature
|
||||
shell: bash
|
||||
run: |
|
||||
git -c tar.tar.gz.command='gzip -cn' archive --format=tar.gz --prefix="${REPO#*/}-${VERSION#v}/" -o "/tmp/${VERSION}.tar.gz" "${VERSION}"
|
||||
gpg -u "$GPG_FINGERPRINT" --armor --output "${VERSION}.tar.gz.asc" --detach-sig "/tmp/${VERSION}.tar.gz"
|
||||
rm "/tmp/${VERSION}.tar.gz"
|
||||
env:
|
||||
GPG_FINGERPRINT: ${{ inputs.gpg-fingerprint }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@dccd6d23e64fd6a746dce6814c0bde0a04886085 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Upload release assets
|
||||
description: Uploads assets to an existing release and optionally signs them
|
||||
inputs:
|
||||
gpg-fingerprint:
|
||||
description: Fingerprint of the GPG key to use for signing the assets, if any.
|
||||
required: false
|
||||
upload-url:
|
||||
description: GitHub release upload URL to upload the assets to.
|
||||
required: true
|
||||
asset-path:
|
||||
description: |
|
||||
The path to the asset you want to upload, if any. You can use glob patterns here.
|
||||
Will be GPG signed and an `.asc` file included in the release artifacts if `gpg-fingerprint` is set.
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Sign assets
|
||||
if: inputs.gpg-fingerprint
|
||||
shell: bash
|
||||
run: |
|
||||
for FILE in $ASSET_PATH
|
||||
do
|
||||
gpg -u "$GPG_FINGERPRINT" --armor --output "$FILE".asc --detach-sig "$FILE"
|
||||
done
|
||||
env:
|
||||
GPG_FINGERPRINT: ${{ inputs.gpg-fingerprint }}
|
||||
ASSET_PATH: ${{ inputs.asset-path }}
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@dccd6d23e64fd6a746dce6814c0bde0a04886085 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@dccd6d23e64fd6a746dce6814c0bde0a04886085 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
@@ -0,0 +1,35 @@
|
||||
name-template: "v$RESOLVED_VERSION"
|
||||
tag-template: "v$RESOLVED_VERSION"
|
||||
change-template: "* $TITLE ([#$NUMBER]($URL)). Contributed by @$AUTHOR."
|
||||
categories:
|
||||
- title: "🚨 BREAKING CHANGES"
|
||||
label: "X-Breaking-Change"
|
||||
- title: "🦖 Deprecations"
|
||||
label: "T-Deprecation"
|
||||
- title: "✨ Features"
|
||||
label: "T-Enhancement"
|
||||
- title: "🐛 Bug Fixes"
|
||||
label: "T-Defect"
|
||||
- title: "🧰 Maintenance"
|
||||
label: "Dependencies"
|
||||
collapse-after: 5
|
||||
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- "X-Breaking-Change"
|
||||
default: minor
|
||||
exclude-labels:
|
||||
- "T-Task"
|
||||
- "X-Reverted"
|
||||
- "backport staging"
|
||||
exclude-contributors:
|
||||
- "RiotRobot"
|
||||
template: |
|
||||
$CHANGES
|
||||
#no-changes-template: ""
|
||||
prerelease: true
|
||||
prerelease-identifier: rc
|
||||
include-pre-releases: false
|
||||
stable-ref: master
|
||||
staging-ref: staging
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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.79.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
|
||||
@@ -11,18 +11,16 @@ jobs:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
workflow: static_analysis.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: docs
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v2
|
||||
uses: matrix-org/netlify-pr-preview@v3
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
@@ -32,3 +30,4 @@ jobs:
|
||||
site_id: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
desc: Documentation preview
|
||||
deployment_env: PR Documentation Preview
|
||||
environment: PR Documentation Preview
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
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
|
||||
pull_request: {}
|
||||
|
||||
# For now at least, we don't run this or the downstream-end-to-end-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:
|
||||
@@ -21,7 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.79.0
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.90.0
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
||||
# matrix-react-sdk playwright tests (with access to repo secrets)
|
||||
|
||||
name: matrix-react-sdk 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:
|
||||
playwright:
|
||||
name: Playwright
|
||||
# We only want to run the playwright tests on merge queue to prevent regressions
|
||||
# from creeping in. They take a long time to run and consume multiple concurrent runners.
|
||||
if: github.event.workflow_run.event == 'merge_group'
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
statuses: write
|
||||
pull-requests: read
|
||||
deployments: write
|
||||
with:
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
# We want to make the Playwright tests a required check for the merge queue.
|
||||
#
|
||||
# Unfortunately, GitHub doesn't distinguish between "checks needed for branch
|
||||
# protection" (ie, the things that must pass before the PR will even be added
|
||||
# to the merge queue) and "checks needed in the merge queue". We just have to add
|
||||
# the check to the branch protection list.
|
||||
#
|
||||
# Ergo, if we know we're not going to run the Playwright tests, we need to add a
|
||||
# passing status check manually.
|
||||
mark_skipped:
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
permissions:
|
||||
statuses: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: success
|
||||
description: Playwright skipped
|
||||
|
||||
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
|
||||
# in matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml.
|
||||
context: "${{ github.workflow }} / end-to-end-tests"
|
||||
|
||||
sha: "${{ github.event.workflow_run.head_sha }}"
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- name: Check membership
|
||||
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
|
||||
uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 # v3
|
||||
id: teams
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Add label
|
||||
if: ${{ steps.teams.outputs.isTeamMember == 'false' }}
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Close pull request
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Release Drafter
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
include-changes:
|
||||
description: Project to include changelog entries from in this release.
|
||||
type: string
|
||||
required: false
|
||||
concurrency: release-drafter-action
|
||||
jobs:
|
||||
draft:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- uses: t3chguy/release-drafter@105e541c2c3d857f032bd522c0764694758fabad
|
||||
id: draft-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
disable-autolabeler: true
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
path: .action-repo
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
scripts/release
|
||||
|
||||
- name: Ingest upstream changes
|
||||
if: inputs.include-changes
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
DEPENDENCY: ${{ inputs.include-changes }}
|
||||
VERSION: ${{ steps.draft-release.outputs.tag_name }}
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const { RELEASE_ID: releaseId, DEPENDENCY, VERSION } = process.env;
|
||||
const { owner, repo } = context.repo;
|
||||
const script = require("./.action-repo/scripts/release/merge-release-notes.js");
|
||||
|
||||
let deps = [];
|
||||
if (DEPENDENCY.includes("/")) {
|
||||
deps.push(DEPENDENCY.replace("$VERSION", VERSION))
|
||||
} else {
|
||||
const fromVersion = JSON.parse(await exec.exec("git show origin/master:package.json")).dependencies[DEPENDENCY];
|
||||
const toVersion = require("./package.json").dependencies[DEPENDENCY];
|
||||
|
||||
if (toVersion.endsWith("#develop")) {
|
||||
core.warning(`${DEPENDENCY} will be kept at ${fromVersion}`, { title: "Develop dependency found" });
|
||||
} else {
|
||||
deps.push([DEPENDENCY, fromVersion, toVersion]);
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.length) {
|
||||
const notes = await script({
|
||||
github,
|
||||
releaseId,
|
||||
dependencies: deps,
|
||||
});
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
body: notes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
name: Release Drafter
|
||||
on:
|
||||
push:
|
||||
branches: [staging]
|
||||
workflow_dispatch: {}
|
||||
concurrency: ${{ github.workflow }}
|
||||
jobs:
|
||||
draft:
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
|
||||
@@ -0,0 +1,85 @@
|
||||
# Gitflow merge-back master->develop
|
||||
name: Merge master -> develop
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_call:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
inputs:
|
||||
dependencies:
|
||||
description: List of dependencies to reset.
|
||||
type: string
|
||||
required: false
|
||||
concurrency: ${{ github.workflow }}
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
path: .action-repo
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: Set up git
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- name: Merge to develop
|
||||
run: |
|
||||
git checkout develop
|
||||
git merge -X ours master
|
||||
|
||||
- name: Run post-merge-master script to revert package.json fields
|
||||
run: ./.action-repo/scripts/release/post-merge-master.sh
|
||||
|
||||
- name: Reset dependencies
|
||||
if: inputs.dependencies
|
||||
run: |
|
||||
while IFS= read -r PACKAGE; do
|
||||
[ -z "$PACKAGE" ] && continue
|
||||
|
||||
CURRENT_VERSION=$(cat package.json | jq -r .dependencies[\"$PACKAGE\"])
|
||||
echo "Current $PACKAGE version is $CURRENT_VERSION"
|
||||
|
||||
if [ "$CURRENT_VERSION" == "null" ]
|
||||
then
|
||||
echo "Unable to find $PACKAGE in package.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_VERSION" == "develop" ]
|
||||
then
|
||||
echo "Not updating dependency $PACKAGE"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Resetting $1 to develop branch..."
|
||||
yarn add "github:matrix-org/$PACKAGE#develop"
|
||||
git add -u
|
||||
git commit -m "Reset $PACKAGE back to develop branch"
|
||||
done <<< "$DEPENDENCIES"
|
||||
env:
|
||||
DEPENDENCIES: ${{ inputs.dependencies }}
|
||||
FINAL: ${{ inputs.final }}
|
||||
|
||||
- name: Push changes
|
||||
run: git push origin develop
|
||||
@@ -0,0 +1,331 @@
|
||||
name: Release Make
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
GPG_PASSPHRASE:
|
||||
required: false
|
||||
GPG_PRIVATE_KEY:
|
||||
required: false
|
||||
inputs:
|
||||
final:
|
||||
description: Make final release
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
npm:
|
||||
description: Publish to npm
|
||||
type: boolean
|
||||
default: false
|
||||
downstreams:
|
||||
description: List of github projects (owner/repo) which should have their dependency bumped to the newly released version (in JSON string array string syntax)
|
||||
type: string
|
||||
required: false
|
||||
include-changes:
|
||||
description: Project to include changelog entries from in this release.
|
||||
type: string
|
||||
required: false
|
||||
gpg-fingerprint:
|
||||
description: Fingerprint of the GPG key to use for signing the git tag and assets, if any.
|
||||
type: string
|
||||
required: false
|
||||
asset-path:
|
||||
description: |
|
||||
The path to the asset you want to upload, if any. You can use glob patterns here.
|
||||
Will be GPG signed and an `.asc` file included in the release artifacts if `gpg-fingerprint` is set.
|
||||
type: string
|
||||
required: false
|
||||
expected-asset-count:
|
||||
description: The number of expected assets, including signatures, excluding generated zip & tarball.
|
||||
type: number
|
||||
required: false
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
environment: Release
|
||||
steps:
|
||||
- name: Load GPG key
|
||||
id: gpg
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
fingerprint: ${{ inputs.gpg-fingerprint }}
|
||||
|
||||
- name: Get draft release
|
||||
id: release
|
||||
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
draft: true
|
||||
latest: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
path: .action-repo
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
scripts/release
|
||||
|
||||
- name: Prepare variables
|
||||
id: prepare
|
||||
run: |
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
HAS_DIST=0
|
||||
jq -e .scripts.dist package.json >/dev/null 2>&1 && HAS_DIST=1
|
||||
echo "has-dist-script=$HAS_DIST" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.tag_name }}
|
||||
|
||||
- name: Finalise version
|
||||
if: inputs.final
|
||||
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Check version number not in use
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { VERSION } = process.env;
|
||||
github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag: VERSION,
|
||||
}).then(() => {
|
||||
core.setFailed(`Version ${VERSION} already exists`);
|
||||
}).catch(() => {
|
||||
// This is fine, we expect there to not be any release with this version yet
|
||||
});
|
||||
|
||||
- name: Set up git
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install dependencies
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: Handle develop dependencies
|
||||
run: |
|
||||
ret=0
|
||||
cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do
|
||||
IFS=" "
|
||||
PACKAGE=${dep[0]}
|
||||
VERSION=${dep[1]}
|
||||
|
||||
echo "::warning title=Develop dependency found::$DEPENDENCY will be kept at $VERSION"
|
||||
yarn upgrade "$PACKAGE@$VERSION" --exact
|
||||
git add -u
|
||||
git commit -m "Keep $PACKAGE at $VERSION"
|
||||
done
|
||||
|
||||
- name: Bump package.json version
|
||||
run: yarn version --no-git-tag-version --new-version "${VERSION#v}"
|
||||
|
||||
- name: Add to CHANGELOG.md
|
||||
if: inputs.final
|
||||
run: |
|
||||
mv CHANGELOG.md CHANGELOG.md.old
|
||||
HEADER="Changes in [${VERSION#v}](https://github.com/${{ github.repository }}/releases/tag/$VERSION) ($(date '+%Y-%m-%d'))"
|
||||
|
||||
{
|
||||
echo "$HEADER"
|
||||
printf '=%.0s' $(seq ${#HEADER})
|
||||
echo ""
|
||||
echo "$RELEASE_NOTES"
|
||||
echo ""
|
||||
} > CHANGELOG.md
|
||||
|
||||
cat CHANGELOG.md.old >> CHANGELOG.md
|
||||
rm CHANGELOG.md.old
|
||||
git add CHANGELOG.md
|
||||
env:
|
||||
RELEASE_NOTES: ${{ steps.release.outputs.body }}
|
||||
|
||||
- name: Run pre-release script to update package.json fields
|
||||
run: |
|
||||
./.action-repo/scripts/release/pre-release.sh
|
||||
git add package.json
|
||||
|
||||
- name: Commit changes
|
||||
run: git commit -m "$VERSION"
|
||||
|
||||
- name: Build assets
|
||||
if: steps.prepare.outputs.has-dist-script == '1'
|
||||
run: DIST_VERSION="$VERSION" yarn dist
|
||||
|
||||
- name: Upload release assets & signatures
|
||||
if: inputs.asset-path
|
||||
uses: ./.action-repo/.github/actions/upload-release-assets
|
||||
with:
|
||||
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
|
||||
upload-url: ${{ steps.release.outputs.upload_url }}
|
||||
asset-path: ${{ inputs.asset-path }}
|
||||
|
||||
- name: Create signed tag
|
||||
if: inputs.gpg-fingerprint
|
||||
run: |
|
||||
GIT_COMMITTER_EMAIL="$SIGNING_ID" GPG_TTY=$(tty) git tag -u "$SIGNING_ID" -m "Release $VERSION" "$VERSION"
|
||||
env:
|
||||
SIGNING_ID: ${{ steps.gpg.outputs.email }}
|
||||
|
||||
- name: Generate & upload tarball signature
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: ./.action-repo/.github/actions/sign-release-tarball
|
||||
with:
|
||||
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
|
||||
upload-url: ${{ steps.release.outputs.upload_url }}
|
||||
|
||||
# We defer pushing changes until after the release assets are built,
|
||||
# signed & uploaded to improve the atomicity of this action.
|
||||
- name: Push changes to staging
|
||||
run: |
|
||||
git push origin staging $TAG
|
||||
git reset --hard
|
||||
env:
|
||||
TAG: ${{ inputs.gpg-fingerprint && env.VERSION || '' }}
|
||||
|
||||
- name: Validate tarball signature
|
||||
if: inputs.gpg-fingerprint
|
||||
run: |
|
||||
wget https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/$VERSION.tar.gz
|
||||
gpg --verify "$VERSION.tar.gz.asc" "$VERSION.tar.gz"
|
||||
|
||||
- name: Validate release has expected assets
|
||||
if: inputs.expected-asset-count
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const { RELEASE_ID: release_id, EXPECTED_ASSET_COUNT } = process.env;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const { data: release } = await github.rest.repos.getRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id,
|
||||
});
|
||||
|
||||
if (release.assets.length !== parseInt(EXPECTED_ASSET_COUNT, 10)) {
|
||||
core.setFailed(`Found ${release.assets.length} assets but expected ${EXPECTED_ASSET_COUNT}`);
|
||||
}
|
||||
|
||||
- name: Merge to master
|
||||
if: inputs.final
|
||||
run: |
|
||||
git checkout master
|
||||
git merge -X theirs staging
|
||||
git push origin master
|
||||
|
||||
- name: Publish release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
FINAL: ${{ inputs.final }}
|
||||
with:
|
||||
retries: 3
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
script: |
|
||||
const { RELEASE_ID: release_id, RELEASE_NOTES, VERSION, FINAL } = process.env;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const opts = {
|
||||
owner,
|
||||
repo,
|
||||
release_id,
|
||||
tag_name: VERSION,
|
||||
name: VERSION,
|
||||
draft: false,
|
||||
body: RELEASE_NOTES,
|
||||
};
|
||||
|
||||
if (FINAL == "true") {
|
||||
opts.prerelease = false;
|
||||
opts.make_latest = true;
|
||||
}
|
||||
|
||||
github.rest.repos.updateRelease(opts);
|
||||
|
||||
npm:
|
||||
name: Publish to npm
|
||||
needs: release
|
||||
if: inputs.npm
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
post-release:
|
||||
name: Post release steps
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: repository
|
||||
run: echo "REPO=${GITHUB_REPOSITORY#*/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Advance release blocker labels
|
||||
uses: garganshu/github-label-updater@3770d15ebfed2fe2cb06a241047bc340f774a7d1 # v1.0.0
|
||||
with:
|
||||
owner: ${{ github.repository_owner }}
|
||||
repo: ${{ steps.repository.outputs.REPO }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
filter-labels: X-Upcoming-Release-Blocker
|
||||
remove-labels: X-Upcoming-Release-Blocker
|
||||
add-labels: X-Release-Blocker
|
||||
|
||||
- name: Wait for master->develop gitflow merge
|
||||
if: inputs.final
|
||||
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
|
||||
with:
|
||||
ref: master
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: merge
|
||||
allowed-conclusions: success
|
||||
|
||||
bump-downstreams:
|
||||
name: Update npm dependency in downstream projects
|
||||
needs: npm
|
||||
runs-on: ubuntu-latest
|
||||
if: inputs.downstreams
|
||||
strategy:
|
||||
matrix:
|
||||
repo: ${{ fromJSON(inputs.downstreams) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ matrix.repo }}
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- name: Bump dependency
|
||||
env:
|
||||
DEPENDENCY: ${{ needs.npm.outputs.id }}
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
yarn upgrade "$DEPENDENCY" --exact
|
||||
git add package.json yarn.lock
|
||||
git commit -am"Upgrade dependency to $DEPENDENCY"
|
||||
git push origin staging
|
||||
@@ -1,4 +1,3 @@
|
||||
# Must only be called from `release#published` triggers
|
||||
name: Publish to npm
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -9,12 +8,16 @@ jobs:
|
||||
npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
id: ${{ steps.npm-publish.outputs.id }}
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -24,7 +27,7 @@ jobs:
|
||||
|
||||
- name: 🚀 Publish to npm
|
||||
id: npm-publish
|
||||
uses: JS-DevTools/npm-publish@5a85faf05d2ade2d5b6682bfe5359915d5159c6c # v2.2.1
|
||||
uses: JS-DevTools/npm-publish@4b07b26a2f6e0a51846e1870223e545bae91c552 # v3.0.1
|
||||
with:
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
access: public
|
||||
@@ -32,7 +35,7 @@ jobs:
|
||||
ignore-scripts: false
|
||||
|
||||
- name: 🎖️ Add `latest` dist-tag to final releases
|
||||
if: github.event.release.prerelease == false && steps.npm-publish.outputs.id
|
||||
if: steps.npm-publish.outputs.id && !contains(steps.npm-publish.outputs.id, '-rc.')
|
||||
run: npm dist-tag add "$release" latest
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
name: Release Process
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: What type of release
|
||||
required: true
|
||||
default: rc
|
||||
type: choice
|
||||
options:
|
||||
- rc
|
||||
- final
|
||||
docs:
|
||||
description: Publish docs
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
npm:
|
||||
description: Publish to npm
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
concurrency: ${{ github.workflow }}
|
||||
jobs:
|
||||
jsdoc:
|
||||
release:
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop
|
||||
secrets: inherit
|
||||
with:
|
||||
final: ${{ inputs.mode == 'final' }}
|
||||
npm: ${{ inputs.npm }}
|
||||
downstreams: '["matrix-org/matrix-react-sdk", "element-hq/element-web"]'
|
||||
|
||||
docs:
|
||||
name: Publish Documentation
|
||||
needs: release
|
||||
if: inputs.docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
@@ -18,7 +46,7 @@ jobs:
|
||||
path: _docs
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
@@ -36,17 +64,14 @@ jobs:
|
||||
yarn gendoc
|
||||
symlinks -rc _docs
|
||||
|
||||
- name: 🚀 Deploy
|
||||
- name: 🔨 Set up git
|
||||
run: |
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: |
|
||||
git add . --all
|
||||
git commit -m "Update docs"
|
||||
git push
|
||||
working-directory: _docs
|
||||
|
||||
npm:
|
||||
name: Publish
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -5,7 +5,13 @@ on:
|
||||
secrets:
|
||||
SONAR_TOKEN:
|
||||
required: true
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
inputs:
|
||||
sharded:
|
||||
type: boolean
|
||||
required: false
|
||||
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
|
||||
extra_args:
|
||||
type: string
|
||||
required: false
|
||||
@@ -13,11 +19,13 @@ on:
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event != 'merge_group'
|
||||
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@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -25,24 +33,57 @@ jobs:
|
||||
sha: ${{ github.event.workflow_run.head_sha }}
|
||||
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: "🧮 Checkout code"
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
if: ${{ !inputs.sharded }}
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: coverage
|
||||
path: coverage
|
||||
- name: 📥 Download sharded artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
if: inputs.sharded
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: coverage-*
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
|
||||
- id: extra_args
|
||||
run: |
|
||||
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.5
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.7
|
||||
# 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:
|
||||
skip_checkout: true
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
version_cmd: "cat package.json | jq -r .version"
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
coverage_run_id: ${{ github.event.workflow_run.id }}
|
||||
coverage_workflow_name: tests.yml
|
||||
coverage_extract_path: coverage
|
||||
extra_args: ${{ inputs.extra_args }}
|
||||
extra_args: |
|
||||
${{ inputs.extra_args }}
|
||||
-Dsonar.javascript.lcov.reportPaths=${{ steps.extra_args.outputs.reportPaths }}
|
||||
-Dsonar.testExecutionReportPaths=${{ steps.extra_args.outputs.testExecutionReportPaths }}
|
||||
|
||||
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -8,38 +8,12 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
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 }}
|
||||
testExecutionReportPaths: ${{ steps.extra_args.outputs.testExecutionReportPaths }}
|
||||
steps:
|
||||
# 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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
|
||||
with:
|
||||
workflow: tests.yaml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: coverage
|
||||
path: coverage
|
||||
|
||||
- id: extra_args
|
||||
run: |
|
||||
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
|
||||
|
||||
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:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
with:
|
||||
extra_args: -Dsonar.javascript.lcov.reportPaths=${{ needs.prepare.outputs.reportPaths }} -Dsonar.testExecutionReportPaths=${{ needs.prepare.outputs.testExecutionReportPaths }}
|
||||
sharded: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
@@ -51,13 +51,29 @@ jobs:
|
||||
- name: Run Linter
|
||||
run: "yarn run lint:js"
|
||||
|
||||
workflow_lint:
|
||||
name: "Workflow Lint"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: Run Linter
|
||||
run: "yarn lint:workflows"
|
||||
|
||||
docs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
@@ -75,7 +91,7 @@ jobs:
|
||||
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs
|
||||
path: _docs
|
||||
|
||||
+10
-13
@@ -12,19 +12,20 @@ env:
|
||||
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
|
||||
jobs:
|
||||
jest:
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
|
||||
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node == '*' && 'latest' || matrix.node }})"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [browserify, integ, unit]
|
||||
node: [18, latest]
|
||||
specs: [integ, unit]
|
||||
node: [18, "lts/*", 21]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
id: setupNode
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -32,13 +33,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
|
||||
- name: Build
|
||||
if: matrix.specs == 'browserify'
|
||||
run: "yarn build"
|
||||
|
||||
- name: Get number of CPU cores
|
||||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
|
||||
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -55,13 +52,13 @@ jobs:
|
||||
|
||||
- name: Move coverage files into place
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
coverage
|
||||
!coverage/lcov-report
|
||||
@@ -85,7 +82,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
|
||||
uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Move labelled issues to correct projects
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
call-triage-labelled:
|
||||
uses: vector-im/element-web/.github/workflows/triage-labelled.yml@develop
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Upgrade Dependencies
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
jobs:
|
||||
upgrade:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: "yarn"
|
||||
|
||||
- name: Upgrade
|
||||
run: yarn upgrade && yarn install
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/upgrade-deps
|
||||
delete-branch: true
|
||||
title: Upgrade dependencies
|
||||
labels: |
|
||||
Dependencies
|
||||
T-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.(ts|tsx)": ["eslint --fix", "prettier --write"],
|
||||
"*.(py|md|yaml)": ["prettier --write"]
|
||||
}
|
||||
+2
-1
@@ -25,5 +25,6 @@ out
|
||||
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
|
||||
/CHANGELOG.md
|
||||
|
||||
# This file is also autogenerated
|
||||
# These files are also autogenerated
|
||||
/spec/test-utils/test-data/index.ts
|
||||
/spec/test-utils/test_indexeddb_cryptostore_dump/dump.json
|
||||
|
||||
+146
@@ -1,3 +1,149 @@
|
||||
Changes in [31.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.2.0) (2024-01-31)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Emit events during migration from libolm ([#3982](https://github.com/matrix-org/matrix-js-sdk/pull/3982)). Contributed by @richvdh.
|
||||
* Support for migration from from libolm ([#3978](https://github.com/matrix-org/matrix-js-sdk/pull/3978)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* ElementR | backup: call expensive `roomKeyCounts` less often ([#4015](https://github.com/matrix-org/matrix-js-sdk/pull/4015)). Contributed by @BillCarsonFr.
|
||||
* Decrypt and Import full backups in chunk with progress ([#4005](https://github.com/matrix-org/matrix-js-sdk/pull/4005)). Contributed by @BillCarsonFr.
|
||||
* Fix new threads not appearing. ([#4009](https://github.com/matrix-org/matrix-js-sdk/pull/4009)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [31.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.1.0) (2024-01-19)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Broaden spec version support ([#4016](https://github.com/matrix-org/matrix-js-sdk/pull/4016)). Contributed by @RiotRobot.
|
||||
|
||||
|
||||
Changes in [31.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.0.0) (2024-01-16)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
|
||||
* Send authenticated /versions request ([#3968](https://github.com/matrix-org/matrix-js-sdk/pull/3968)). Contributed by @dbkr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Revert "Bump matrix-sdk-crypto-wasm to 3.6.0" ([#3991](https://github.com/matrix-org/matrix-js-sdk/pull/3991)). Contributed by @andybalaam.
|
||||
* #22606 Fix "Remove" button to users without "m.room.redaction" ([#3981](https://github.com/matrix-org/matrix-js-sdk/pull/3981)). Contributed by @rashmitpankhania.
|
||||
* ElementR: Ensure Encryption order per room ([#3973](https://github.com/matrix-org/matrix-js-sdk/pull/3973)). Contributed by @BillCarsonFr.
|
||||
* Element-R: fix `bootstrapSecretStorage` not resetting key backup when requested ([#3976](https://github.com/matrix-org/matrix-js-sdk/pull/3976)). Contributed by @uhoreg.
|
||||
|
||||
|
||||
Changes in [30.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.3.0) (2023-12-19)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Element-R: disable sending room key requests ([#3939](https://github.com/matrix-org/matrix-js-sdk/pull/3939)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix notifications appearing for old events ([#3946](https://github.com/matrix-org/matrix-js-sdk/pull/3946)). Contributed by @dbkr.
|
||||
* Don't back up keys that we got from backup ([#3934](https://github.com/matrix-org/matrix-js-sdk/pull/3934)). Contributed by @uhoreg.
|
||||
* Fix upload with empty Content-Type ([#3918](https://github.com/matrix-org/matrix-js-sdk/pull/3918)). Contributed by @JakubOnderka.
|
||||
* Prevent phantom notifications from events not in a room's timeline ([#3942](https://github.com/matrix-org/matrix-js-sdk/pull/3942)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [30.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.2.0) (2023-12-05)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Only await key query after lazy members resolved ([#3902](https://github.com/matrix-org/matrix-js-sdk/pull/3902)). Contributed by @BillCarsonFr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Rewrite receipt-handling code ([#3901](https://github.com/matrix-org/matrix-js-sdk/pull/3901)). Contributed by @andybalaam.
|
||||
* Explicitly free some Rust-side objects ([#3911](https://github.com/matrix-org/matrix-js-sdk/pull/3911)). Contributed by @richvdh.
|
||||
* Fix type for TimestampToEventResponse.origin\_server\_ts ([#3906](https://github.com/matrix-org/matrix-js-sdk/pull/3906)). Contributed by @Half-Shot.
|
||||
|
||||
|
||||
Changes in [30.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.1.0) (2023-11-21)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Rotate per-participant keys when a member leaves ([#3833](https://github.com/matrix-org/matrix-js-sdk/pull/3833)). Contributed by @dbkr.
|
||||
* Add E2EE for embedded mode of Element Call ([#3667](https://github.com/matrix-org/matrix-js-sdk/pull/3667)). Contributed by @SimonBrandner.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Shorten TimelineWindow when an event is removed ([#3862](https://github.com/matrix-org/matrix-js-sdk/pull/3862)). Contributed by @andybalaam.
|
||||
* Ignore receipts pointing at missing or invalid events ([#3817](https://github.com/matrix-org/matrix-js-sdk/pull/3817)). Contributed by @andybalaam.
|
||||
* Fix members being loaded from server on initial sync (defeating lazy loading) ([#3830](https://github.com/matrix-org/matrix-js-sdk/pull/3830)). Contributed by @BillCarsonFr.
|
||||
|
||||
|
||||
Changes in [30.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.1) (2023-11-13)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Ensure `setUserCreator` is called when a store is assigned ([\#3867](https://github.com/matrix-org/matrix-js-sdk/pull/3867)). Fixes vector-im/element-web#26520. Contributed by @MidhunSureshR.
|
||||
|
||||
Changes in [30.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.0) (2023-11-07)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Refactor & make base64 functions browser-safe ([\#3818](https://github.com/matrix-org/matrix-js-sdk/pull/3818)).
|
||||
* `IndexedDBStore.startup()` must be called after using it on `sdk.createClient` now.
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate `MatrixEvent.toJSON` ([\#3801](https://github.com/matrix-org/matrix-js-sdk/pull/3801)).
|
||||
|
||||
## ✨ Features
|
||||
* Element-R: Add the git sha of the binding crate to `CryptoApi#getVersion` ([\#3838](https://github.com/matrix-org/matrix-js-sdk/pull/3838)). Contributed by @florianduros.
|
||||
* Element-R: Wire up `globalBlacklistUnverifiedDevices` field to rust crypto encryption settings ([\#3790](https://github.com/matrix-org/matrix-js-sdk/pull/3790)). Fixes vector-im/element-web#26315. Contributed by @florianduros.
|
||||
* Element-R: Wire up room rotation ([\#3807](https://github.com/matrix-org/matrix-js-sdk/pull/3807)). Fixes vector-im/element-web#26318. Contributed by @florianduros.
|
||||
* Element-R: Add current version of the rust-sdk and vodozemac ([\#3825](https://github.com/matrix-org/matrix-js-sdk/pull/3825)). Contributed by @florianduros.
|
||||
* Element-R: Wire up room history visibility ([\#3805](https://github.com/matrix-org/matrix-js-sdk/pull/3805)). Fixes vector-im/element-web#26319. Contributed by @florianduros.
|
||||
* Element-R: log when we send to-device messages ([\#3810](https://github.com/matrix-org/matrix-js-sdk/pull/3810)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix reemitter not being correctly wired on user objects created in storage classes ([\#3796](https://github.com/matrix-org/matrix-js-sdk/pull/3796)). Contributed by @MidhunSureshR.
|
||||
* Element-R: silence log errors when viewing a pending event ([\#3824](https://github.com/matrix-org/matrix-js-sdk/pull/3824)).
|
||||
* Don't emit a closed event if the indexeddb is closed by Element ([\#3832](https://github.com/matrix-org/matrix-js-sdk/pull/3832)). Fixes vector-im/element-web#25941. Contributed by @dhenneke.
|
||||
* Element-R: silence log errors when viewing a decryption failure ([\#3821](https://github.com/matrix-org/matrix-js-sdk/pull/3821)).
|
||||
|
||||
Changes in [29.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.1.0) (2023-10-24)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* OIDC: refresh tokens ([\#3764](https://github.com/matrix-org/matrix-js-sdk/pull/3764)). Contributed by @kerryarchibald.
|
||||
* OIDC: add `prompt` param to auth url creation ([\#3794](https://github.com/matrix-org/matrix-js-sdk/pull/3794)). Contributed by @kerryarchibald.
|
||||
* Allow applications to specify their own logger instance ([\#3792](https://github.com/matrix-org/matrix-js-sdk/pull/3792)). Fixes #1899.
|
||||
* Export AutoDiscoveryError and fix type of ALL_ERRORS ([\#3768](https://github.com/matrix-org/matrix-js-sdk/pull/3768)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix sending call member events on leave ([\#3799](https://github.com/matrix-org/matrix-js-sdk/pull/3799)). Fixes vector-im/element-call#1763.
|
||||
* Don't use event.sender in CallMembership ([\#3793](https://github.com/matrix-org/matrix-js-sdk/pull/3793)).
|
||||
* Element-R: Don't mark QR code verification as done until it's done ([\#3791](https://github.com/matrix-org/matrix-js-sdk/pull/3791)). Fixes vector-im/element-web#26293.
|
||||
* Element-R: Connect device to key backup when crypto is created ([\#3784](https://github.com/matrix-org/matrix-js-sdk/pull/3784)). Fixes vector-im/element-web#26316. Contributed by @florianduros.
|
||||
* Element-R: Avoid errors in `VerificationRequest.generateQRCode` when QR code is unavailable ([\#3779](https://github.com/matrix-org/matrix-js-sdk/pull/3779)). Fixes vector-im/element-web#26300. Contributed by @florianduros.
|
||||
* ElementR: Check key backup when user identity changes ([\#3760](https://github.com/matrix-org/matrix-js-sdk/pull/3760)). Fixes vector-im/element-web#26244. Contributed by @florianduros.
|
||||
* Element-R: emit `VerificationRequestReceived` on incoming request ([\#3762](https://github.com/matrix-org/matrix-js-sdk/pull/3762)). Fixes vector-im/element-web#26245.
|
||||
|
||||
Changes in [29.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v29.0.0) (2023-10-10)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Remove browserify builds ([\#3759](https://github.com/matrix-org/matrix-js-sdk/pull/3759)).
|
||||
|
||||
## ✨ Features
|
||||
* Export AutoDiscoveryError and fix type of ALL_ERRORS ([\#3768](https://github.com/matrix-org/matrix-js-sdk/pull/3768)).
|
||||
* Support for stable MSC3882 get_login_token ([\#3416](https://github.com/matrix-org/matrix-js-sdk/pull/3416)). Contributed by @hughns.
|
||||
* Remove IsUserMention and IsRoomMention from DEFAULT_OVERRIDE_RULES ([\#3752](https://github.com/matrix-org/matrix-js-sdk/pull/3752)). Contributed by @kerryarchibald.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix a case where joinRoom creates a duplicate Room object ([\#3747](https://github.com/matrix-org/matrix-js-sdk/pull/3747)).
|
||||
* Add membershipID to call memberships ([\#3745](https://github.com/matrix-org/matrix-js-sdk/pull/3745)).
|
||||
* Fix the warning for messages from unsigned devices ([\#3743](https://github.com/matrix-org/matrix-js-sdk/pull/3743)).
|
||||
* Stop keep alive, when sync was stoped ([\#3720](https://github.com/matrix-org/matrix-js-sdk/pull/3720)). Contributed by @finsterwalder.
|
||||
|
||||
Changes in [28.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v28.2.0) (2023-09-26)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -23,19 +23,7 @@ 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`
|
||||
attached to `window` through which you can access the SDK. See below for how to
|
||||
include libolm to enable end-to-end-encryption.
|
||||
|
||||
The browser bundle supports recent versions of browsers. Typically this is ES2015
|
||||
or `> 0.5%, last 2 versions, Firefox ESR, not dead` if using
|
||||
[browserlists](https://github.com/browserslist/browserslist).
|
||||
|
||||
Please check [the working browser example](examples/browser) for more information.
|
||||
### Note, the browserify build has been removed. Please use a bundler like webpack or vite instead.
|
||||
|
||||
## In Node.js
|
||||
|
||||
@@ -70,7 +58,7 @@ await client.startClient({ initialSyncLimit: 10 });
|
||||
You can perform a call to `/sync` to get the current state of the client:
|
||||
|
||||
```javascript
|
||||
client.once("sync", function (state, prevState, res) {
|
||||
client.once(ClientEvent.sync, function (state, prevState, res) {
|
||||
if (state === "PREPARED") {
|
||||
console.log("prepared");
|
||||
} else {
|
||||
@@ -95,7 +83,7 @@ client.sendEvent("roomId", "m.room.message", content, "", (err, res) => {
|
||||
To listen for message events:
|
||||
|
||||
```javascript
|
||||
client.on("Room.timeline", function (event, room, toStartOfTimeline) {
|
||||
client.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
|
||||
if (event.getType() !== "m.room.message") {
|
||||
return; // only use messages
|
||||
}
|
||||
@@ -156,12 +144,12 @@ are updated.
|
||||
|
||||
```javascript
|
||||
// Listen for low-level MatrixEvents
|
||||
client.on("event", function (event) {
|
||||
client.on(ClientEvent.Event, function (event) {
|
||||
console.log(event.getType());
|
||||
});
|
||||
|
||||
// Listen for typing changes
|
||||
client.on("RoomMember.typing", function (event, member) {
|
||||
client.on(RoomMemberEvent.Typing, function (event, member) {
|
||||
if (member.typing) {
|
||||
console.log(member.name + " is typing...");
|
||||
} else {
|
||||
@@ -223,7 +211,7 @@ const matrixClient = sdk.createClient({
|
||||
### Automatically join rooms when invited
|
||||
|
||||
```javascript
|
||||
matrixClient.on("RoomMember.membership", function (event, member) {
|
||||
matrixClient.on(RoomMemberEvent.Membership, function (event, member) {
|
||||
if (member.membership === "invite" && member.userId === myUserId) {
|
||||
matrixClient.joinRoom(member.roomId).then(function () {
|
||||
console.log("Auto-joined %s", member.roomId);
|
||||
@@ -237,7 +225,7 @@ matrixClient.startClient();
|
||||
### Print out messages for all rooms
|
||||
|
||||
```javascript
|
||||
matrixClient.on("Room.timeline", function (event, room, toStartOfTimeline) {
|
||||
matrixClient.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
|
||||
if (toStartOfTimeline) {
|
||||
return; // don't print paginated results
|
||||
}
|
||||
@@ -269,7 +257,7 @@ Output:
|
||||
### Print out membership lists whenever they are changed
|
||||
|
||||
```javascript
|
||||
matrixClient.on("RoomState.members", function (event, state, member) {
|
||||
matrixClient.on(RoomStateEvent.Members, function (event, state, member) {
|
||||
const room = matrixClient.getRoom(state.roomId);
|
||||
if (!room) {
|
||||
return;
|
||||
@@ -363,7 +351,7 @@ First, you need to pull in the right build tools:
|
||||
|
||||
## Building
|
||||
|
||||
To build a browser version from scratch when developing::
|
||||
To build a browser version from scratch when developing:
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
@@ -375,9 +363,6 @@ To run tests (Jest):
|
||||
$ yarn test
|
||||
```
|
||||
|
||||
> **Note**
|
||||
> The `sync-browserify.spec.ts` requires a browser build (`yarn build`) in order to pass
|
||||
|
||||
To run linting:
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Summary
|
||||
|
||||
- [Introduction](../README.md)
|
||||
|
||||
# Deep dive
|
||||
|
||||
- [Storage notes](storage-notes.md)
|
||||
- [Unverified devices](warning-on-unverified-devices.md)
|
||||
@@ -1,31 +1,29 @@
|
||||
Random notes from Matthew on the two possible approaches for warning users about unexpected
|
||||
unverified devices popping up in their rooms....
|
||||
|
||||
Original idea...
|
||||
================
|
||||
# Original idea...
|
||||
|
||||
Warn when an existing user adds an unknown device to a room.
|
||||
|
||||
Warn when a user joins the room with unverified or unknown devices.
|
||||
|
||||
Warn when you initial sync if the room has any unverified devices in it.
|
||||
^ this is good enough if we're doing local storage.
|
||||
OR, better:
|
||||
^ this is good enough if we're doing local storage.
|
||||
OR, better:
|
||||
Warn when you initial sync if the room has any new undefined devices since you were last there.
|
||||
=> This means persisting the rooms that devices are in, across initial syncs.
|
||||
=> This means persisting the rooms that devices are in, across initial syncs.
|
||||
|
||||
|
||||
Updated idea...
|
||||
===============
|
||||
# Updated idea...
|
||||
|
||||
Warn when the user tries to send a message:
|
||||
- If the room has unverified devices which the user has not yet been told about in the context of this room
|
||||
...or in the context of this user? currently all verification is per-user, not per-room.
|
||||
|
||||
- If the room has unverified devices which the user has not yet been told about in the context of this room
|
||||
...or in the context of this user? currently all verification is per-user, not per-room.
|
||||
...this should be good enough.
|
||||
|
||||
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
|
||||
- so track whether we have warned the user or not about unverified devices - blocked, unverified, verified, unverified_warned.
|
||||
throw an error when trying to encrypt if there are pure unverified devices there
|
||||
app will have to search for the devices which are pure unverified to warn about them - have to do this from MembersList anyway?
|
||||
- or megolm could warn which devices are causing the problems.
|
||||
- or megolm could warn which devices are causing the problems.
|
||||
|
||||
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
|
||||
Why do we wait to establish outbound sessions? It just makes a horrible pause when we first try to send a message... but could otherwise unnecessarily consume resources?
|
||||
@@ -1,10 +0,0 @@
|
||||
To try it out, **you must build the SDK first** and then host this folder:
|
||||
|
||||
```
|
||||
$ yarn install
|
||||
$ yarn build
|
||||
$ cd examples/browser
|
||||
$ python -m http.server 8003
|
||||
```
|
||||
|
||||
Then visit `http://localhost:8003`.
|
||||
@@ -1,9 +0,0 @@
|
||||
console.log("Loading browser sdk");
|
||||
|
||||
var client = matrixcs.createClient({ baseUrl: "https://matrix.org" });
|
||||
client.publicRooms().then(function (data) {
|
||||
console.log("data %s [...]", JSON.stringify(data).substring(0, 100));
|
||||
console.log("Congratulations! The SDK is working on the browser!");
|
||||
var result = document.getElementById("result");
|
||||
result.innerHTML = "<p>The SDK appears to be working correctly.</p>";
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="data:," />
|
||||
<script src="lib/matrix.js"></script>
|
||||
<script src="browserTest.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
Sanity Testing (check the console) : This example is here to make sure that the SDK works inside a browser. It
|
||||
simply does a GET /publicRooms on matrix.org
|
||||
<br />
|
||||
You should see a message confirming that the SDK works below:
|
||||
<br />
|
||||
<div id="result"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
../../../dist/browser-matrix.js
|
||||
@@ -1,2 +0,0 @@
|
||||
olm.js
|
||||
olm.wasm
|
||||
@@ -1 +0,0 @@
|
||||
../../../dist/browser-matrix.js
|
||||
@@ -1,60 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Test Crypto in Browser</title>
|
||||
<script src="lib/olm.js"></script>
|
||||
<script src="lib/matrix.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing export/import of Olm devices in the browser</h1>
|
||||
<ul>
|
||||
<li>Make sure you built the current version of the Matrix JS SDK (<code>yarn build</code>)</li>
|
||||
<li>
|
||||
copy <code>olm.js</code> and <code>olm.wasm</code> from a recent release of Olm (was tested with version
|
||||
3.1.4) in directory <code>lib/</code>
|
||||
</li>
|
||||
<li>start a local Matrix homeserver (on port 8008, or change the port in the code)</li>
|
||||
<li>Serve this HTML file (e.g. <code>python3 -m http.server</code>) and go to it through your browser</li>
|
||||
<li>
|
||||
in the JS console, do:
|
||||
<pre>
|
||||
aliceMatrixClient = await newMatrixClient("alice-"+randomHex());
|
||||
await aliceMatrixClient.exportDevice();
|
||||
await aliceMatrixClient.getAccessToken();
|
||||
</pre
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
copy the result of <code>exportDevice</code> and <code>getAccessToken</code> somewhere (<strong
|
||||
>not</strong
|
||||
>
|
||||
in a JS variable as it will be destroyed when you refresh the page)
|
||||
</li>
|
||||
<li><strong>refresh the page (F5)</strong> to make sure the client is destroyed</li>
|
||||
<li>
|
||||
Do the following, replacing <code>ALICE_ID</code>
|
||||
with the user ID of Alice (you can find it in the exported data)
|
||||
<pre>
|
||||
bobMatrixClient = await newMatrixClient("bob-"+randomHex());
|
||||
roomId = await bobMatrixClient.createEncryptedRoom([ALICE_ID]);
|
||||
await bobMatrixClient.sendTextMessage('Hi Alice!', roomId);
|
||||
</pre
|
||||
>
|
||||
</li>
|
||||
<li>Again, <strong>refresh the page (F5)</strong>. You may want to clear your console as well.</li>
|
||||
<li>
|
||||
Now do the following, using the exported data and the access token you saved previously:
|
||||
<pre>
|
||||
aliceMatrixClient = await importMatrixClient(EXPORTED_DATA, ACCESS_TOKEN);
|
||||
</pre
|
||||
>
|
||||
</li>
|
||||
<li>You should see the message sent by Bob printed in the console.</li>
|
||||
</ul>
|
||||
|
||||
<script src="olm-device-export-import.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +0,0 @@
|
||||
if (!Olm) {
|
||||
console.error("global.Olm does not seem to be present." + " Did you forget to add olm in the lib/ directory?");
|
||||
}
|
||||
|
||||
const BASE_URL = "http://localhost:8008";
|
||||
const ROOM_CRYPTO_CONFIG = { algorithm: "m.megolm.v1.aes-sha2" };
|
||||
const PASSWORD = "password";
|
||||
|
||||
// useful to create new usernames
|
||||
window.randomHex = () => Math.floor(Math.random() * 10 ** 6).toString(16);
|
||||
|
||||
window.newMatrixClient = async function (username) {
|
||||
const registrationClient = matrixcs.createClient(BASE_URL);
|
||||
|
||||
const userRegisterResult = await registrationClient.register(username, PASSWORD, null, { type: "m.login.dummy" });
|
||||
|
||||
const matrixClient = matrixcs.createClient({
|
||||
baseUrl: BASE_URL,
|
||||
userId: userRegisterResult.user_id,
|
||||
accessToken: userRegisterResult.access_token,
|
||||
deviceId: userRegisterResult.device_id,
|
||||
sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage),
|
||||
cryptoStore: new matrixcs.MemoryCryptoStore(),
|
||||
});
|
||||
|
||||
extendMatrixClient(matrixClient);
|
||||
|
||||
await matrixClient.initCrypto();
|
||||
await matrixClient.startClient();
|
||||
return matrixClient;
|
||||
};
|
||||
|
||||
window.importMatrixClient = async function (exportedDevice, accessToken) {
|
||||
const matrixClient = matrixcs.createClient({
|
||||
baseUrl: BASE_URL,
|
||||
deviceToImport: exportedDevice,
|
||||
accessToken,
|
||||
sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage),
|
||||
cryptoStore: new matrixcs.MemoryCryptoStore(),
|
||||
});
|
||||
|
||||
extendMatrixClient(matrixClient);
|
||||
|
||||
await matrixClient.initCrypto();
|
||||
await matrixClient.startClient();
|
||||
return matrixClient;
|
||||
};
|
||||
|
||||
function extendMatrixClient(matrixClient) {
|
||||
// automatic join
|
||||
matrixClient.on("RoomMember.membership", async (event, member) => {
|
||||
if (member.membership === "invite" && member.userId === matrixClient.getUserId()) {
|
||||
await matrixClient.joinRoom(member.roomId);
|
||||
// setting up of room encryption seems to be triggered automatically
|
||||
// but if we don't wait for it the first messages we send are unencrypted
|
||||
await matrixClient.setRoomEncryption(member.roomId, { algorithm: "m.megolm.v1.aes-sha2" });
|
||||
}
|
||||
});
|
||||
|
||||
matrixClient.onDecryptedMessage = (message) => {
|
||||
console.log("Got encrypted message: ", message);
|
||||
};
|
||||
|
||||
matrixClient.on("Event.decrypted", (event) => {
|
||||
if (event.getType() === "m.room.message") {
|
||||
matrixClient.onDecryptedMessage(event.getContent().body);
|
||||
} else {
|
||||
console.log("decrypted an event of type", event.getType());
|
||||
console.log(event);
|
||||
}
|
||||
});
|
||||
|
||||
matrixClient.createEncryptedRoom = async function (usersToInvite) {
|
||||
const { room_id: roomId } = await this.createRoom({
|
||||
visibility: "private",
|
||||
invite: usersToInvite,
|
||||
});
|
||||
|
||||
// matrixClient.setRoomEncryption() only updates local state
|
||||
// but does not send anything to the server
|
||||
// (see https://github.com/matrix-org/matrix-js-sdk/issues/905)
|
||||
// so we do it ourselves with 'sendStateEvent'
|
||||
await this.sendStateEvent(roomId, "m.room.encryption", ROOM_CRYPTO_CONFIG);
|
||||
await this.setRoomEncryption(roomId, ROOM_CRYPTO_CONFIG);
|
||||
|
||||
// Marking all devices as verified
|
||||
let room = this.getRoom(roomId);
|
||||
let members = (await room.getEncryptionTargetMembers()).map((x) => x["userId"]);
|
||||
let memberkeys = await this.downloadKeys(members);
|
||||
for (const userId in memberkeys) {
|
||||
for (const deviceId in memberkeys[userId]) {
|
||||
await this.setDeviceVerified(userId, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return roomId;
|
||||
};
|
||||
|
||||
matrixClient.sendTextMessage = async function (message, roomId) {
|
||||
return matrixClient.sendMessage(roomId, {
|
||||
body: message,
|
||||
msgtype: "m.text",
|
||||
});
|
||||
};
|
||||
}
|
||||
+23
-47
@@ -1,26 +1,24 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "28.2.0",
|
||||
"version": "31.3.0-rc.2",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "yarn build",
|
||||
"prepack": "yarn build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"dist": "echo 'This is for the release script so it can make assets (browser bundle).' && yarn build",
|
||||
"clean": "rimraf lib dist",
|
||||
"build": "yarn build:dev && yarn build:compile-browser && yarn build:minify-browser",
|
||||
"clean": "rimraf lib",
|
||||
"build": "yarn build:dev",
|
||||
"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 && 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",
|
||||
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
|
||||
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
|
||||
"lint:js-fix": "prettier --loglevel=warn --write . && eslint --fix src spec",
|
||||
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"coverage": "yarn test --coverage"
|
||||
@@ -42,7 +40,6 @@
|
||||
"author": "matrix.org",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"lib",
|
||||
"src",
|
||||
"git-revision.txt",
|
||||
@@ -55,7 +52,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^1.2.3-alpha.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^4.3.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -70,6 +67,8 @@
|
||||
"uuid": "9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@action-validator/cli": "^0.5.3",
|
||||
"@action-validator/core": "^0.5.3",
|
||||
"@babel/cli": "^7.12.10",
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/eslint-parser": "^7.12.10",
|
||||
@@ -82,8 +81,8 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"@casualbot/jest-sonar-reporter": "^2.2.5",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
@@ -92,38 +91,35 @@
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"allchange": "^1.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"allchange": "^1.3.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
"babelify": "^10.0.0",
|
||||
"browserify": "^17.0.0",
|
||||
"browserify-swap": "^0.2.2",
|
||||
"debug": "^4.3.4",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^46.0.0",
|
||||
"eslint-plugin-jsdoc": "^48.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^48.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"fake-indexeddb": "^5.0.2",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"husky": "^8.0.3",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^29.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"prettier": "3.2.4",
|
||||
"rimraf": "^5.0.0",
|
||||
"terser": "^5.5.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.24.0",
|
||||
"typedoc-plugin-coverage": "^2.1.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
@@ -137,25 +133,5 @@
|
||||
"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
-23
@@ -10,28 +10,6 @@ set -e
|
||||
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 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.
|
||||
if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then
|
||||
# If there's a `src` prefixed value, use that, otherwise delete.
|
||||
# This is used to delete the `typings` field and reset `main` back
|
||||
# to the TypeScript source.
|
||||
src_value=$(jq -r ".matrix_src_$i" package.json)
|
||||
if [ "$src_value" != "null" ]; then
|
||||
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
else
|
||||
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$(git ls-files --modified package.json)" ]; then
|
||||
echo "Committing develop package.json"
|
||||
git commit package.json -m "Resetting package fields for development"
|
||||
fi
|
||||
|
||||
"$(dirname "$0")/scripts/release/post-merge-master.sh"
|
||||
git push origin develop
|
||||
fi
|
||||
|
||||
+1
-12
@@ -175,18 +175,7 @@ echo "yarn version"
|
||||
# manually commit the result.
|
||||
yarn version --no-git-tag-version --new-version "$release"
|
||||
|
||||
# For the published and dist versions of the package, we copy the
|
||||
# `matrix_lib_main` and `matrix_lib_typings` fields to `main` and `typings` (if
|
||||
# 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 browser
|
||||
do
|
||||
lib_value=$(jq -r ".matrix_lib_$i" package.json)
|
||||
if [ "$lib_value" != "null" ]; then
|
||||
jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
fi
|
||||
done
|
||||
"$(dirname "$0")/scripts/release/pre-release.sh"
|
||||
|
||||
# commit yarn.lock if it exists, is versioned, and is modified
|
||||
if [[ -f yarn.lock && $(git status --porcelain yarn.lock | grep '^ M') ]];
|
||||
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
// Dependency can be the name of an entry in package.json, in which case the owner, repo & version will be looked up in its own package.json
|
||||
// Or it can be a string in the form owner/repo@tag
|
||||
// Or it can be a tuple of dependency, from version, to version, in which case a list of releases in that range (to inclusive) will be returned
|
||||
async function getReleases(github, dependency) {
|
||||
if (Array.isArray(dependency)) {
|
||||
const [dep, fromVersion, toVersion] = dependency;
|
||||
const upstreamPackageJson = getDependencyPackageJson(dep);
|
||||
const [owner, repo] = upstreamPackageJson.repository.url.split("/").slice(-2);
|
||||
|
||||
const response = await github.rest.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const releases = response.data.filter((release) => !release.draft && !release.prerelease);
|
||||
|
||||
const fromVersionIndex = releases.findIndex((release) => release.tag_name === `v${fromVersion}`);
|
||||
const toVersionIndex = releases.findIndex((release) => release.tag_name === `v${toVersion}`);
|
||||
|
||||
return releases.slice(toVersionIndex, fromVersionIndex);
|
||||
}
|
||||
|
||||
return [await getRelease(github, dependency)];
|
||||
}
|
||||
|
||||
async function getRelease(github, dependency) {
|
||||
let owner;
|
||||
let repo;
|
||||
let tag;
|
||||
if (dependency.includes("/") && dependency.includes("@")) {
|
||||
owner = dependency.split("/")[0];
|
||||
repo = dependency.split("/")[1].split("@")[0];
|
||||
tag = dependency.split("@")[1];
|
||||
} else {
|
||||
const upstreamPackageJson = getDependencyPackageJson(dependency);
|
||||
[owner, repo] = upstreamPackageJson.repository.url.split("/").slice(-2);
|
||||
tag = `v${upstreamPackageJson.version}`;
|
||||
}
|
||||
|
||||
const response = await github.rest.repos.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function getDependencyPackageJson(dependency) {
|
||||
return JSON.parse(fs.readFileSync(`./node_modules/${dependency}/package.json`, "utf8"));
|
||||
}
|
||||
|
||||
const HEADING_PREFIX = "## ";
|
||||
|
||||
const categories = [
|
||||
"🔒 SECURITY FIXES",
|
||||
"🚨 BREAKING CHANGESd",
|
||||
"🦖 Deprecations",
|
||||
"✨ Features",
|
||||
"🐛 Bug Fixes",
|
||||
"🧰 Maintenance",
|
||||
];
|
||||
|
||||
const parseReleaseNotes = (body, sections) => {
|
||||
let heading = null;
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith(HEADING_PREFIX)) {
|
||||
heading = trimmed.slice(HEADING_PREFIX.length);
|
||||
if (!categories.includes(heading)) heading = null;
|
||||
continue;
|
||||
}
|
||||
if (heading && trimmed) {
|
||||
sections[heading].push(trimmed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const main = async ({ github, releaseId, dependencies }) => {
|
||||
const { GITHUB_REPOSITORY } = process.env;
|
||||
const [owner, repo] = GITHUB_REPOSITORY.split("/");
|
||||
|
||||
const sections = Object.fromEntries(categories.map((cat) => [cat, []]));
|
||||
for (const dependency of dependencies) {
|
||||
const releases = await getReleases(github, dependency);
|
||||
for (const release of releases) {
|
||||
parseReleaseNotes(release.body, sections);
|
||||
}
|
||||
}
|
||||
|
||||
const { data: release } = await github.rest.repos.getRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
|
||||
const intro = release.body.split(HEADING_PREFIX, 2)[0].trim();
|
||||
|
||||
let output = "";
|
||||
if (intro) {
|
||||
output = intro + "\n\n";
|
||||
}
|
||||
|
||||
for (const section in sections) {
|
||||
const lines = sections[section];
|
||||
if (!lines.length) continue;
|
||||
output += HEADING_PREFIX + section + "\n\n";
|
||||
output += lines.join("\n");
|
||||
output += "\n\n";
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
// This is just for testing locally
|
||||
// Needs environment variables GITHUB_TOKEN & GITHUB_REPOSITORY
|
||||
if (require.main === module) {
|
||||
const { Octokit } = require("@octokit/rest");
|
||||
const github = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
if (process.argv.length < 4) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Usage: node merge-release-notes.js owner/repo:release_id npm-package-name ...");
|
||||
process.exit(1);
|
||||
}
|
||||
const [releaseId, ...dependencies] = process.argv.slice(2);
|
||||
main({ github, releaseId, dependencies }).then((output) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(output);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# When merging to develop, we need revert the `main` and `typings` fields if we adjusted them previously.
|
||||
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.
|
||||
if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then
|
||||
# If there's a `src` prefixed value, use that, otherwise delete.
|
||||
# This is used to delete the `typings` field and reset `main` back to the TypeScript source.
|
||||
src_value=$(jq -r ".matrix_src_$i" package.json)
|
||||
if [ "$src_value" != "null" ]; then
|
||||
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
else
|
||||
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$(git ls-files --modified package.json)" ]; then
|
||||
echo "Committing develop package.json"
|
||||
git commit package.json -m "Resetting package fields for development"
|
||||
fi
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# For the published and dist versions of the package,
|
||||
# we copy the `matrix_lib_main` and `matrix_lib_typings` fields to `main` and `typings` (if 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 browser
|
||||
do
|
||||
lib_value=$(jq -r ".matrix_lib_$i" package.json)
|
||||
if [ "$lib_value" != "null" ]; then
|
||||
jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json && yarn prettier --write package.json
|
||||
fi
|
||||
done
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 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 "../../dist/browser-matrix"; // uses browser-matrix instead of the src
|
||||
import type { default as BrowserMatrix } from "../../src/browser-index";
|
||||
|
||||
// stub for browser-matrix browserify tests
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = jest.fn();
|
||||
|
||||
afterAll(() => {
|
||||
// clean up XMLHttpRequest mock
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = undefined;
|
||||
});
|
||||
|
||||
// Akin to spec/setupTests.ts - but that won't affect the browser-matrix bundle
|
||||
global.matrixcs = {
|
||||
...global.matrixcs,
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
} as typeof BrowserMatrix;
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 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 HttpBackend from "matrix-mock-request";
|
||||
|
||||
import "./setupTests"; // uses browser-matrix instead of the src
|
||||
import type { MatrixClient } from "../../src";
|
||||
|
||||
const USER_ID = "@user:test.server";
|
||||
const DEVICE_ID = "device_id";
|
||||
const ACCESS_TOKEN = "access_token";
|
||||
const ROOM_ID = "!room_id:server.test";
|
||||
|
||||
describe("Browserify Test", function () {
|
||||
let client: MatrixClient;
|
||||
let httpBackend: HttpBackend;
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend = new HttpBackend();
|
||||
client = new global.matrixcs.MatrixClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: USER_ID,
|
||||
accessToken: ACCESS_TOKEN,
|
||||
deviceId: DEVICE_ID,
|
||||
fetchFn: httpBackend.fetchFn as typeof global.fetch,
|
||||
});
|
||||
|
||||
httpBackend.when("GET", "/versions").respond(200, {});
|
||||
httpBackend.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
client.stopClient();
|
||||
client.http.abort();
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
await httpBackend.stop();
|
||||
});
|
||||
|
||||
it("Sync", async () => {
|
||||
const event = {
|
||||
type: "m.room.member",
|
||||
room_id: ROOM_ID,
|
||||
content: {
|
||||
membership: "join",
|
||||
name: "Displayname",
|
||||
},
|
||||
event_id: "$foobar",
|
||||
};
|
||||
|
||||
const syncData = {
|
||||
next_batch: "batch1",
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [event],
|
||||
limited: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
httpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
httpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
const syncPromise = new Promise((r) => client.once(global.matrixcs.ClientEvent.Sync, r));
|
||||
const unexpectedErrorFn = jest.fn();
|
||||
client.once(global.matrixcs.ClientEvent.SyncUnexpectedError, unexpectedErrorFn);
|
||||
|
||||
client.startClient();
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await syncPromise;
|
||||
expect(unexpectedErrorFn).not.toHaveBeenCalled();
|
||||
}, 20000); // additional timeout as this test can take quite a while
|
||||
});
|
||||
@@ -31,9 +31,12 @@ import {
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
|
||||
} from "../../test-utils/test-data";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -97,6 +100,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
/** an object which intercepts `/keys/upload` requests on the test homeserver */
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
|
||||
// Silence warnings from the backup manager
|
||||
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND" },
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
@@ -236,6 +245,53 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[ed25519:${SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it("can bootstrapCrossSigning twice", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// a second call should do nothing except GET requests
|
||||
fetchMock.mockClear();
|
||||
await bootstrapCrossSigning(authDict);
|
||||
const calls = fetchMock.calls((url, opts) => opts.method != "GET");
|
||||
expect(calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
newBackendOnly("will upload existing cross-signing keys to an established secret storage", async () => {
|
||||
// This rather obscure codepath covers the case that:
|
||||
// - 4S is set up and working
|
||||
// - our device has private cross-signing keys, but has not published them to 4S
|
||||
//
|
||||
// To arrange that, we call `bootstrapCrossSigning` on our main device, and then (pretend to) set up 4S from
|
||||
// a *different* device. Then, when we call `bootstrapCrossSigning` again, it should do the honours.
|
||||
|
||||
mockSetupCrossSigningRequests();
|
||||
const accountDataAccumulator = new AccountDataAccumulator();
|
||||
accountDataAccumulator.interceptGetAccountData();
|
||||
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// Pretend that another device has uploaded a 4S key
|
||||
accountDataAccumulator.accountDataEvents.set("m.secret_storage.default_key", { key: "key_id" });
|
||||
accountDataAccumulator.accountDataEvents.set("m.secret_storage.key.key_id", {
|
||||
key: "keykeykey",
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
});
|
||||
|
||||
// Prepare for the cross-signing keys
|
||||
const p = accountDataAccumulator.interceptSetAccountData(":type(m.cross_signing..*)");
|
||||
|
||||
await bootstrapCrossSigning(authDict);
|
||||
await p;
|
||||
|
||||
// The cross-signing keys should have been uploaded
|
||||
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.master")).toBeTruthy();
|
||||
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.self_signing")).toBeTruthy();
|
||||
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.user_signing")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningStatus()", () => {
|
||||
@@ -339,4 +395,49 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
|
||||
expect(userSigningKeyId).toBe(getPubKey(crossSigningKeys.user_signing_key));
|
||||
});
|
||||
});
|
||||
|
||||
describe("crossSignDevice", () => {
|
||||
beforeEach(async () => {
|
||||
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
// make sure that there is another device which we can sign
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
|
||||
// Complete initialsync, to get the outgoing requests going
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// Wait for legacy crypto to find the device
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([aliceClient.getSafeUserId()]);
|
||||
expect(devices.get(aliceClient.getSafeUserId())!.has(testData.TEST_DEVICE_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("fails for an unknown device", async () => {
|
||||
await expect(aliceClient.getCrypto()!.crossSignDevice("unknown")).rejects.toThrow("Unknown device");
|
||||
});
|
||||
|
||||
it("cross-signs the device", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
await aliceClient.getCrypto()!.bootstrapCrossSigning({});
|
||||
|
||||
fetchMock.mockClear();
|
||||
await aliceClient.getCrypto()!.crossSignDevice(testData.TEST_DEVICE_ID);
|
||||
|
||||
// check that a sig for the device was uploaded
|
||||
const calls = fetchMock.calls("upload-sigs");
|
||||
expect(calls.length).toEqual(1);
|
||||
const body = JSON.parse(calls[0][1]!.body as string);
|
||||
const deviceSig = body[aliceClient.getSafeUserId()][testData.TEST_DEVICE_ID];
|
||||
expect(deviceSig).toHaveProperty("signatures");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+768
-542
File diff suppressed because it is too large
Load Diff
@@ -17,16 +17,34 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { Mocked } from "jest-mock";
|
||||
|
||||
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient, TypedEventEmitter } from "../../../src";
|
||||
import {
|
||||
createClient,
|
||||
CryptoApi,
|
||||
CryptoEvent,
|
||||
ICreateClientOpts,
|
||||
IEvent,
|
||||
IMegolmSessionData,
|
||||
MatrixClient,
|
||||
TypedEventEmitter,
|
||||
} 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 {
|
||||
advanceTimersUntil,
|
||||
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";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { defer, IDeferred } from "../../../src/utils";
|
||||
|
||||
const ROOM_ID = testData.TEST_ROOM_ID;
|
||||
|
||||
@@ -110,9 +128,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
beforeEach(async () => {
|
||||
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
@@ -134,6 +153,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function initTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
@@ -149,62 +169,149 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
return client;
|
||||
}
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async function () {
|
||||
const syncResponse = {
|
||||
describe("Key backup check on UTD message", () => {
|
||||
// sync response which contains an encrypted event
|
||||
const SYNC_RESPONSE = {
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[ROOM_ID]: {
|
||||
timeline: {
|
||||
events: [testData.ENCRYPTED_EVENT],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
||||
};
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
);
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
const EXPECTED_URL =
|
||||
[
|
||||
"https://alice-server.com/_matrix/client/v3/room_keys/keys",
|
||||
encodeURIComponent(testData.TEST_ROOM_ID),
|
||||
encodeURIComponent(testData.MEGOLM_SESSION_DATA.session_id),
|
||||
].join("/") + "?version=1";
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"));
|
||||
/** Flush promises enough times to get the crypto stacks to make the backup request */
|
||||
async function flushBackupRequest() {
|
||||
// we have to run flushPromises lots of times. It seems like each time the rust code touches indexeddb,
|
||||
// it needs another round of flushPromises to progress, or something.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await flushPromises();
|
||||
}
|
||||
}
|
||||
|
||||
// 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.getCrypto()!.checkKeyBackupAndEnable();
|
||||
|
||||
// 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(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
it("can restore from backup (Curve25519 version)", async function () {
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// 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 aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
});
|
||||
|
||||
it("Alice checks key backups when receiving a message she can't decrypt", async () => {
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id", (url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == "1") {
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "1",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
});
|
||||
|
||||
it("handles error on backup query gracefully", async () => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
||||
{ name: "getKey" },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await flushBackupRequest();
|
||||
|
||||
const calls = fetchMock.calls("getKey");
|
||||
expect(calls.length).toEqual(1);
|
||||
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
||||
|
||||
await flushBackupRequest();
|
||||
|
||||
// we should not have logged an error.
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Only queries once", async () => {
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
{ status: 404, body: { errcode: "M_NOT_FOUND" } },
|
||||
{ name: "getKey" },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await flushBackupRequest();
|
||||
const calls = fetchMock.calls("getKey");
|
||||
expect(calls.length).toEqual(1);
|
||||
expect(calls[0][0]).toEqual(EXPECTED_URL);
|
||||
|
||||
fetchMock.resetHistory();
|
||||
|
||||
// another message
|
||||
const event2 = { ...testData.ENCRYPTED_EVENT, event_id: "$event2" };
|
||||
const syncResponse2 = {
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [event2] } } } },
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse(syncResponse2);
|
||||
await flushBackupRequest();
|
||||
expect(fetchMock.calls("getKey").length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recover from backup", () => {
|
||||
let aliceCrypto: CryptoApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
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);
|
||||
});
|
||||
|
||||
it("can restore from backup (Curve25519 version)", async function () {
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
@@ -224,32 +331,203 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
onKeyCached = resolve;
|
||||
});
|
||||
|
||||
const result = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
|
||||
await awaitKeyCached;
|
||||
|
||||
// The key should be now cached
|
||||
const afterCache = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithCache(undefined, undefined, check!.backupInfo!),
|
||||
);
|
||||
|
||||
expect(afterCache.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock backup response of a GET `room_keys/keys` with a given number of keys per room.
|
||||
* @param keysPerRoom The number of keys per room
|
||||
*/
|
||||
function createBackupDownloadResponse(keysPerRoom: number[]) {
|
||||
const response: {
|
||||
rooms: {
|
||||
[roomId: string]: {
|
||||
sessions: {
|
||||
[sessionId: string]: KeyBackupSession;
|
||||
};
|
||||
};
|
||||
};
|
||||
} = { rooms: {} };
|
||||
|
||||
const expectedTotal = keysPerRoom.reduce((a, b) => a + b, 0);
|
||||
for (let i = 0; i < keysPerRoom.length; i++) {
|
||||
const roomId = `!room${i}:example.com`;
|
||||
response.rooms[roomId] = { sessions: {} };
|
||||
for (let j = 0; j < keysPerRoom[i]; j++) {
|
||||
const sessionId = `session${j}`;
|
||||
// Put the same fake session data, not important for that test
|
||||
response.rooms[roomId].sessions[sessionId] = testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
}
|
||||
}
|
||||
return { response, expectedTotal };
|
||||
}
|
||||
|
||||
it("Should import full backup in chunks", async function () {
|
||||
const importMockImpl = jest.fn();
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.importBackedUpRoomKeys = importMockImpl;
|
||||
|
||||
// We need several rooms with several sessions to test chunking
|
||||
const { response, expectedTotal } = createBackupDownloadResponse([45, 300, 345, 12, 130]);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const progressCallback = jest.fn();
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
cacheCompleteCallback: () => onKeyCached(),
|
||||
progressCallback,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
expect(result.imported).toStrictEqual(expectedTotal);
|
||||
// Should be called 5 times: 200*4 plus one chunk with the remaining 32
|
||||
expect(importMockImpl).toHaveBeenCalledTimes(5);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(importMockImpl.mock.calls[i][0].length).toEqual(200);
|
||||
}
|
||||
expect(importMockImpl.mock.calls[4][0].length).toEqual(32);
|
||||
|
||||
await awaitKeyCached;
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
stage: "fetch",
|
||||
});
|
||||
|
||||
// Should be called 4 times and report 200/400/600/800
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: (i + 1) * 200,
|
||||
stage: "load_keys",
|
||||
failures: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// The last chunk
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: 832,
|
||||
stage: "load_keys",
|
||||
failures: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should continue to process backup if a chunk import fails and report failures", async function () {
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.importBackedUpRoomKeys = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
// Fail to import first chunk
|
||||
throw new Error("test error");
|
||||
})
|
||||
// Ok for other chunks
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const { response, expectedTotal } = createBackupDownloadResponse([100, 300]);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const progressCallback = jest.fn();
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
{
|
||||
progressCallback,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.total).toStrictEqual(expectedTotal);
|
||||
// A chunk failed to import
|
||||
expect(result.imported).toStrictEqual(200);
|
||||
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: 0,
|
||||
stage: "load_keys",
|
||||
failures: 200,
|
||||
});
|
||||
|
||||
expect(progressCallback).toHaveBeenCalledWith({
|
||||
total: expectedTotal,
|
||||
successes: 200,
|
||||
stage: "load_keys",
|
||||
failures: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("Should continue if some keys fails to decrypt", async function () {
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.importBackedUpRoomKeys = jest.fn();
|
||||
|
||||
const decryptionFailureCount = 2;
|
||||
|
||||
const mockDecryptor = {
|
||||
// DecryptSessions does not reject on decryption failure, but just skip the key
|
||||
decryptSessions: jest.fn().mockImplementation((sessions) => {
|
||||
// simulate fail to decrypt 2 keys out of all
|
||||
const decrypted = [];
|
||||
const keys = Object.keys(sessions);
|
||||
for (let i = 0; i < keys.length - decryptionFailureCount; i++) {
|
||||
decrypted.push({
|
||||
session_id: keys[i],
|
||||
} as unknown as Mocked<IMegolmSessionData>);
|
||||
}
|
||||
return decrypted;
|
||||
}),
|
||||
free: jest.fn(),
|
||||
};
|
||||
|
||||
// @ts-ignore - mock a private method for testing purpose
|
||||
aliceCrypto.getBackupDecryptor = jest.fn().mockResolvedValue(mockDecryptor);
|
||||
|
||||
const { response, expectedTotal } = createBackupDownloadResponse([100]);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
undefined,
|
||||
undefined,
|
||||
check!.backupInfo!,
|
||||
);
|
||||
|
||||
expect(result.total).toStrictEqual(expectedTotal);
|
||||
// A chunk failed to import
|
||||
expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount);
|
||||
});
|
||||
|
||||
it("recover specific session from backup", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
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);
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
testData.CURVE25519_KEY_BACKUP_DATA,
|
||||
@@ -257,27 +535,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
|
||||
const check = await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
ROOM_ID,
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
check!.backupInfo!,
|
||||
const result = await advanceTimersUntil(
|
||||
aliceClient.restoreKeyBackupWithRecoveryKey(
|
||||
testData.BACKUP_DECRYPTION_KEY_BASE58,
|
||||
ROOM_ID,
|
||||
testData.MEGOLM_SESSION_DATA.session_id,
|
||||
check!.backupInfo!,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.imported).toStrictEqual(1);
|
||||
});
|
||||
|
||||
it("Fails on bad recovery key", async function () {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
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);
|
||||
|
||||
const fullBackup = {
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
@@ -640,6 +910,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const result = await aliceCrypto.isKeyBackupTrusted(testData.SIGNED_BACKUP_DATA);
|
||||
@@ -653,6 +924,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
await aliceClient.startClient();
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
const backup: KeyBackupInfo = JSON.parse(JSON.stringify(testData.SIGNED_BACKUP_DATA));
|
||||
@@ -784,6 +1056,146 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backup Changed from other sessions", () => {
|
||||
beforeEach(async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// ignore requests to send room key requests
|
||||
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
|
||||
|
||||
aliceClient = await initTestClient();
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
testData.SIGNED_BACKUP_DATA.version!,
|
||||
);
|
||||
|
||||
// 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 aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
|
||||
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
|
||||
});
|
||||
|
||||
// let aliceClient: MatrixClient;
|
||||
|
||||
const SYNC_RESPONSE = {
|
||||
next_batch: 1,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
|
||||
};
|
||||
|
||||
it("If current backup has changed, the manager should switch to the new one on UTD", async () => {
|
||||
// =====
|
||||
// First ensure that the client checks for keys using the backup version 1
|
||||
/// =====
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
(url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == "1") {
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "1",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
|
||||
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(ROOM_ID)!;
|
||||
const event = room.getLiveTimeline().getEvents()[0];
|
||||
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
|
||||
|
||||
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
|
||||
|
||||
// =====
|
||||
// Second suppose now that the backup has changed to version 2
|
||||
/// =====
|
||||
|
||||
const newBackup = {
|
||||
...testData.SIGNED_BACKUP_DATA,
|
||||
version: "2",
|
||||
};
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, { overwriteRoutes: true });
|
||||
// suppose the new key is now known
|
||||
const aliceCrypto = aliceClient.getCrypto()!;
|
||||
await aliceCrypto.storeSessionBackupPrivateKey(
|
||||
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
|
||||
newBackup.version,
|
||||
);
|
||||
|
||||
// A check backup should happen at some point
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
(url, request) => {
|
||||
// check that the version is correct
|
||||
const version = new URLSearchParams(new URL(url).search).get("version");
|
||||
if (version == newBackup.version) {
|
||||
awaitHasQueriedNewBackup.resolve();
|
||||
return testData.CURVE25519_KEY_BACKUP_DATA;
|
||||
} else {
|
||||
// awaitHasQueriedOldBackup.resolve();
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
current_version: "2",
|
||||
errcode: "M_WRONG_ROOM_KEYS_VERSION",
|
||||
error: "Wrong backup version.",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the new backup.
|
||||
const newMessage: Partial<IEvent> = {
|
||||
type: "m.room.encrypted",
|
||||
room_id: "!room:id",
|
||||
sender: "@alice:localhost",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
ciphertext:
|
||||
"AwgAEpABKvf9FqPW52zeHfeVTn90a3jlBLlx7g6VDEkc2089RQUJoWpSJRiK13E83rN41wgGFJccyfoCr7ZDGJeuGYMGETTrgnLQhLs6JmyPf37JYkzxW8uS8rGUKEqTFQriKhibHVLvVacOlSIObUiKU/V3r176XuixqZF/4eyK9A22JNpInbgI10ZUT6LnApH9LR3FpZbE2zImf1uNPuvp7r0xQbW7CcJjqpH+qTPBD5zFdFnMkc2SnbXCsIOaX11Dm0krWfQz7iA26ZnI1nyZnyh7XPrCnJCRsuQH",
|
||||
device_id: "WVMJGTSSVB",
|
||||
sender_key: "E5RiY/YCIrHWaF4u416CqvblC6udK2jt9SJ/h1QeLS0",
|
||||
session_id: "ybnW+LGdUhoS4fHm1DAEphukO3sZ1GCqZD7UQz7L+GA",
|
||||
},
|
||||
event_id: "$event2",
|
||||
origin_server_ts: 1507753887000,
|
||||
};
|
||||
|
||||
const nextSyncResponse = {
|
||||
next_batch: 2,
|
||||
rooms: { join: { [ROOM_ID]: { timeline: { events: [newMessage] } } } },
|
||||
};
|
||||
syncResponder.sendOrQueueSyncResponse(nextSyncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
await awaitHasQueriedNewBackup.promise;
|
||||
});
|
||||
});
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
Copyright 2016-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 Olm from "@matrix-org/olm";
|
||||
import anotherjson from "another-json";
|
||||
|
||||
import { IContent, IDeviceKeys, IDownloadKeyResult, IEvent, Keys, MatrixClient, SigningKeys } from "../../../src";
|
||||
import { IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { syncPromise } from "../../test-utils/test-utils";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
|
||||
/**
|
||||
* @module
|
||||
*
|
||||
* A set of utilities for creating Olm accounts and sessions, and encrypting/decrypting with Olm/Megolm.
|
||||
*/
|
||||
|
||||
/** Create an Olm Account object */
|
||||
export async function createOlmAccount(): Promise<Olm.Account> {
|
||||
await Olm.init();
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
return testOlmAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device keys for the test Olm Account
|
||||
*
|
||||
* @param olmAccount - Test olm account
|
||||
* @param userId - The user ID to present the keys as belonging to
|
||||
*/
|
||||
export function getTestOlmAccountKeys(olmAccount: Olm.Account, userId: string, deviceId: string): IDeviceKeys {
|
||||
const testE2eKeys = JSON.parse(olmAccount.identity_keys());
|
||||
const testDeviceKeys: IDeviceKeys = {
|
||||
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
device_id: deviceId,
|
||||
keys: {
|
||||
[`curve25519:${deviceId}`]: testE2eKeys.curve25519,
|
||||
[`ed25519:${deviceId}`]: testE2eKeys.ed25519,
|
||||
},
|
||||
user_id: userId,
|
||||
};
|
||||
|
||||
const j = anotherjson.stringify(testDeviceKeys);
|
||||
const sig = olmAccount.sign(j);
|
||||
testDeviceKeys.signatures = { [userId]: { [`ed25519:${deviceId}`]: sig } };
|
||||
return testDeviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap cross signing for the given Olm account.
|
||||
*
|
||||
* Will generate the cross signing keys and sign them with the master key, and returns the `IDownloadKeyResult`
|
||||
* that can be directly fed into a test e2eKeyResponder.
|
||||
*
|
||||
* The cross-signing keys are randomly generated, similar to how the olm account keys are generated. There may not
|
||||
* be any value in using static vectors, as the device keys change at every test run.
|
||||
*
|
||||
* If some `KeyBackupInfo` are provided, the `auth_data` of each backup info will be signed with the
|
||||
* master key, meaning the backups will be then trusted after verification.
|
||||
*
|
||||
* @param olmAccount - The Olm account object to use for signing the device keys.
|
||||
* @param userId - The user ID to associate with the device keys.
|
||||
* @param deviceId - The device ID to associate with the device keys.
|
||||
* @param keyBackupInfo - Optional key backup infos to sign with the master key.
|
||||
* @returns A valid keys/query response that can be fed into a test e2eKeyResponder.
|
||||
*/
|
||||
export function bootstrapCrossSigningTestOlmAccount(
|
||||
olmAccount: Olm.Account,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
keyBackupInfo: KeyBackupInfo[] = [],
|
||||
): Partial<IDownloadKeyResult> {
|
||||
const olmAliceMSK = new global.Olm.PkSigning();
|
||||
const masterPrivkey = olmAliceMSK.generate_seed();
|
||||
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
|
||||
|
||||
const olmAliceUSK = new global.Olm.PkSigning();
|
||||
const userPrivkey = olmAliceUSK.generate_seed();
|
||||
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
|
||||
|
||||
const olmAliceSSK = new global.Olm.PkSigning();
|
||||
const sskPrivkey = olmAliceSSK.generate_seed();
|
||||
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
|
||||
|
||||
const mskInfo: Keys = {
|
||||
user_id: userId,
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
["ed25519:" + masterPubkey]: masterPubkey,
|
||||
},
|
||||
};
|
||||
|
||||
const sskInfo: Partial<SigningKeys> = {
|
||||
user_id: userId,
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
["ed25519:" + sskPubkey]: sskPubkey,
|
||||
},
|
||||
};
|
||||
// sign the ssk with the msk
|
||||
const sskSig = olmAliceMSK.sign(anotherjson.stringify(sskInfo));
|
||||
sskInfo.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: sskSig,
|
||||
},
|
||||
};
|
||||
|
||||
const uskInfo: Partial<SigningKeys> = {
|
||||
user_id: userId,
|
||||
usage: ["user_signing"],
|
||||
keys: {
|
||||
["ed25519:" + userPubkey]: userPubkey,
|
||||
},
|
||||
};
|
||||
|
||||
// sign the usk with the msk
|
||||
const uskSig = olmAliceMSK.sign(anotherjson.stringify(uskInfo));
|
||||
uskInfo.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: uskSig,
|
||||
},
|
||||
};
|
||||
|
||||
// get the device keys and sign them with the ssk (the device is then cross signed)
|
||||
const deviceKeys = getTestOlmAccountKeys(olmAccount, userId, deviceId);
|
||||
|
||||
const copy = Object.assign({}, deviceKeys);
|
||||
delete copy.signatures;
|
||||
const crossSignature = olmAliceSSK.sign(anotherjson.stringify(copy));
|
||||
|
||||
// add the signature
|
||||
deviceKeys.signatures![userId]["ed25519:" + sskPubkey] = crossSignature;
|
||||
|
||||
// if we have some key backup info, sign them with the msk
|
||||
keyBackupInfo.forEach((info) => {
|
||||
const unsignedAuthData = Object.assign({}, info.auth_data);
|
||||
delete unsignedAuthData.signatures;
|
||||
const backupSignature = olmAliceMSK.sign(anotherjson.stringify(unsignedAuthData));
|
||||
|
||||
info.auth_data.signatures = {
|
||||
[userId]: {
|
||||
["ed25519:" + masterPubkey]: backupSignature,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// clean the olm resources as we don't need them anymore
|
||||
olmAliceMSK.free();
|
||||
olmAliceSSK.free();
|
||||
olmAliceUSK.free();
|
||||
|
||||
return {
|
||||
master_keys: { [userId]: mskInfo },
|
||||
user_signing_keys: { [userId]: uskInfo as SigningKeys },
|
||||
self_signing_keys: { [userId]: sskInfo as SigningKeys },
|
||||
device_keys: { [userId]: { [deviceId]: deviceKeys } },
|
||||
};
|
||||
}
|
||||
|
||||
/** start an Olm session with a given recipient */
|
||||
export async function createOlmSession(
|
||||
olmAccount: Olm.Account,
|
||||
recipientTestClient: IE2EKeyReceiver,
|
||||
): Promise<Olm.Session> {
|
||||
const keys = await recipientTestClient.awaitOneTimeKeyUpload();
|
||||
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;
|
||||
}
|
||||
|
||||
// IToDeviceEvent isn't exported by src/sync-accumulator.ts
|
||||
export interface ToDeviceEvent {
|
||||
content: IContent;
|
||||
sender: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** encrypt an event with an existing olm session */
|
||||
export function encryptOlmEvent(opts: {
|
||||
/** the sender's user id */
|
||||
sender?: string;
|
||||
/** the sender's curve25519 key */
|
||||
senderKey: string;
|
||||
/** the sender's ed25519 key */
|
||||
senderSigningKey: string;
|
||||
/** the olm session to use for encryption */
|
||||
p2pSession: Olm.Session;
|
||||
/** the recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** the payload of the message */
|
||||
plaincontent?: object;
|
||||
/** the event type of the payload */
|
||||
plaintype?: string;
|
||||
}): ToDeviceEvent {
|
||||
expect(opts.senderKey).toBeTruthy();
|
||||
expect(opts.p2pSession).toBeTruthy();
|
||||
expect(opts.recipient).toBeTruthy();
|
||||
|
||||
const plaintext = {
|
||||
content: opts.plaincontent || {},
|
||||
recipient: opts.recipient,
|
||||
recipient_keys: {
|
||||
ed25519: opts.recipientEd25519Key,
|
||||
},
|
||||
keys: {
|
||||
ed25519: opts.senderSigningKey,
|
||||
},
|
||||
sender: opts.sender || "@bob:xyz",
|
||||
type: opts.plaintype || "m.test",
|
||||
};
|
||||
|
||||
return {
|
||||
content: {
|
||||
algorithm: "m.olm.v1.curve25519-aes-sha2",
|
||||
ciphertext: {
|
||||
[opts.recipientCurve25519Key]: opts.p2pSession.encrypt(JSON.stringify(plaintext)),
|
||||
},
|
||||
sender_key: opts.senderKey,
|
||||
},
|
||||
sender: opts.sender || "@bob:xyz",
|
||||
type: "m.room.encrypted",
|
||||
};
|
||||
}
|
||||
|
||||
// encrypt an event with megolm
|
||||
export function encryptMegolmEvent(opts: {
|
||||
senderKey: string;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
plaintext?: Partial<IEvent>;
|
||||
room_id?: string;
|
||||
}): IEvent {
|
||||
expect(opts.senderKey).toBeTruthy();
|
||||
expect(opts.groupSession).toBeTruthy();
|
||||
|
||||
const plaintext = opts.plaintext || {};
|
||||
if (!plaintext.content) {
|
||||
plaintext.content = {
|
||||
body: "42",
|
||||
msgtype: "m.text",
|
||||
};
|
||||
}
|
||||
if (!plaintext.type) {
|
||||
plaintext.type = "m.room.message";
|
||||
}
|
||||
if (!plaintext.room_id) {
|
||||
expect(opts.room_id).toBeTruthy();
|
||||
plaintext.room_id = opts.room_id;
|
||||
}
|
||||
return encryptMegolmEventRawPlainText({
|
||||
senderKey: opts.senderKey,
|
||||
groupSession: opts.groupSession,
|
||||
plaintext,
|
||||
});
|
||||
}
|
||||
|
||||
export function encryptMegolmEventRawPlainText(opts: {
|
||||
senderKey: string;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
plaintext: Partial<IEvent>;
|
||||
origin_server_ts?: number;
|
||||
}): IEvent {
|
||||
return {
|
||||
event_id: "$test_megolm_event_" + Math.random(),
|
||||
sender: opts.plaintext.sender ?? "@not_the_real_sender:example.com",
|
||||
origin_server_ts: opts.plaintext.origin_server_ts ?? 1672944778000,
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)),
|
||||
device_id: "testDevice",
|
||||
sender_key: opts.senderKey,
|
||||
session_id: opts.groupSession.session_id(),
|
||||
},
|
||||
type: "m.room.encrypted",
|
||||
unsigned: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** build an encrypted room_key event to share a group session, using an existing olm session */
|
||||
export function encryptGroupSessionKey(opts: {
|
||||
/** recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** sender's olm account */
|
||||
olmAccount: Olm.Account;
|
||||
/** sender's olm session with the recipient */
|
||||
p2pSession: Olm.Session;
|
||||
groupSession: Olm.OutboundGroupSession;
|
||||
room_id?: string;
|
||||
}): ToDeviceEvent {
|
||||
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
|
||||
return encryptOlmEvent({
|
||||
senderKey: senderKeys.curve25519,
|
||||
senderSigningKey: senderKeys.ed25519,
|
||||
recipient: opts.recipient,
|
||||
recipientCurve25519Key: opts.recipientCurve25519Key,
|
||||
recipientEd25519Key: opts.recipientEd25519Key,
|
||||
p2pSession: opts.p2pSession,
|
||||
plaincontent: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: opts.room_id,
|
||||
session_id: opts.groupSession.session_id(),
|
||||
session_key: opts.groupSession.session_key(),
|
||||
},
|
||||
plaintype: "m.room_key",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test utility to correctly encrypt a secret send event to a test device using the provided p2p session.
|
||||
*
|
||||
* @param opts - the options for the secret send event
|
||||
* @returns the to-device event, ready to be returned in a sync response for the test device.
|
||||
*/
|
||||
export function encryptSecretSend(opts: {
|
||||
/** the sender's user id */
|
||||
sender: string;
|
||||
/** recipient's user id */
|
||||
recipient: string;
|
||||
/** the recipient's curve25519 key */
|
||||
recipientCurve25519Key: string;
|
||||
/** the recipient's ed25519 key */
|
||||
recipientEd25519Key: string;
|
||||
/** sender's olm account */
|
||||
olmAccount: Olm.Account;
|
||||
/** sender's olm session with the recipient */
|
||||
p2pSession: Olm.Session;
|
||||
/** The requestId of the secret request that this secret send is replying. */
|
||||
requestId: string;
|
||||
/** The secret value */
|
||||
secret: string;
|
||||
}): ToDeviceEvent {
|
||||
const senderKeys = JSON.parse(opts.olmAccount.identity_keys());
|
||||
return encryptOlmEvent({
|
||||
sender: opts.sender,
|
||||
senderKey: senderKeys.curve25519,
|
||||
senderSigningKey: senderKeys.ed25519,
|
||||
recipient: opts.recipient,
|
||||
recipientCurve25519Key: opts.recipientCurve25519Key,
|
||||
recipientEd25519Key: opts.recipientEd25519Key,
|
||||
p2pSession: opts.p2pSession,
|
||||
plaincontent: {
|
||||
request_id: opts.requestId,
|
||||
secret: opts.secret,
|
||||
},
|
||||
plaintype: "m.secret.send",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish an Olm Session with the test user
|
||||
*
|
||||
* Waits for the test user to upload their keys, then sends a /sync response with a to-device message which will
|
||||
* establish an Olm session.
|
||||
*
|
||||
* @param testClient - the MatrixClient under test, which we expect to upload account keys, and to make a
|
||||
* /sync request which we will respond to.
|
||||
* @param keyReceiver - an IE2EKeyReceiver which will intercept the /keys/upload request from the client under test
|
||||
* @param syncResponder - an ISyncResponder which will intercept /sync requests from the client under test
|
||||
* @param peerOlmAccount: an OlmAccount which will be used to initiate the Olm session.
|
||||
*/
|
||||
export async function establishOlmSession(
|
||||
testClient: MatrixClient,
|
||||
keyReceiver: IE2EKeyReceiver,
|
||||
syncResponder: ISyncResponder,
|
||||
peerOlmAccount: Olm.Account,
|
||||
): Promise<Olm.Session> {
|
||||
const peerE2EKeys = JSON.parse(peerOlmAccount.identity_keys());
|
||||
const p2pSession = await createOlmSession(peerOlmAccount, keyReceiver);
|
||||
const olmEvent = encryptOlmEvent({
|
||||
senderKey: peerE2EKeys.curve25519,
|
||||
senderSigningKey: peerE2EKeys.ed25519,
|
||||
recipient: testClient.getUserId()!,
|
||||
recipientCurve25519Key: keyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: keyReceiver.getSigningKey(),
|
||||
p2pSession: p2pSession,
|
||||
});
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
to_device: { events: [olmEvent] },
|
||||
});
|
||||
await syncPromise(testClient);
|
||||
return p2pSession;
|
||||
}
|
||||
@@ -16,8 +16,12 @@ limitations under the License.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { createClient } from "../../../src";
|
||||
import { createClient, CryptoEvent, IndexedDBCryptoStore } from "../../../src";
|
||||
import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -41,7 +45,7 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
await expect(() => unknownDeviceClient.initRustCrypto()).rejects.toThrow("unknown deviceId");
|
||||
});
|
||||
|
||||
it("should create the indexed dbs", async () => {
|
||||
it("should create the indexed db", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
@@ -53,7 +57,25 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// should have two dbs now
|
||||
// should have an indexed db now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the meta db if given a pickleKey", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
pickleKey: "testKey",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// should have two indexed dbs now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(
|
||||
expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto", "matrix-js-sdk::matrix-sdk-crypto-meta"]),
|
||||
@@ -70,6 +92,70 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
await matrixClient.initRustCrypto();
|
||||
await matrixClient.initRustCrypto();
|
||||
});
|
||||
|
||||
it("should migrate from libolm", async () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
|
||||
auth_data: {
|
||||
public_key: "q+HZiJdHl2Yopv9GGvv7EYSzDMrAiRknK4glSdoaomI",
|
||||
signatures: {
|
||||
"@vdhtest200713:matrix.org": {
|
||||
"ed25519:gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI":
|
||||
"reDp6Mu+j+tfUL3/T6f5OBT3N825Lzpc43vvG+RvjX6V+KxXzodBQArgCoeEHLtL9OgSBmNrhTkSOX87MWCKAw",
|
||||
"ed25519:KMFSTJSMLB":
|
||||
"F8tyV5W6wNi0GXTdSg+gxSCULQi0EYxdAAqfkyNq58KzssZMw5i+PRA0aI2b+D7NH/aZaJrtiYNHJ0gWLSQvAw",
|
||||
},
|
||||
},
|
||||
},
|
||||
version: "7",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
etag: "1",
|
||||
count: 79,
|
||||
});
|
||||
|
||||
const testStoreName = "test-store";
|
||||
await populateStore(testStoreName);
|
||||
const cryptoStore = new IndexedDBCryptoStore(indexedDB, testStoreName);
|
||||
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@vdhtest200713:matrix.org",
|
||||
deviceId: "KMFSTJSMLB",
|
||||
cryptoStore,
|
||||
pickleKey: "+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o",
|
||||
});
|
||||
|
||||
const progressListener = jest.fn();
|
||||
matrixClient.addListener(CryptoEvent.LegacyCryptoStoreMigrationProgress, progressListener);
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
// Do some basic checks on the imported data
|
||||
const deviceKeys = await matrixClient.getCrypto()!.getOwnDeviceKeys();
|
||||
expect(deviceKeys.curve25519).toEqual("LKv0bKbc0EC4h0jknbemv3QalEkeYvuNeUXVRgVVTTU");
|
||||
expect(deviceKeys.ed25519).toEqual("qK70DEqIXq7T+UU3v/al47Ab4JkMEBLpNrTBMbS5rrw");
|
||||
|
||||
expect(await matrixClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual("7");
|
||||
|
||||
expect(await matrixClient.getCrypto()!.isEncryptionEnabledInRoom("!CWLUCoEWXSFyTCOtfL:matrix.org")).toBe(true);
|
||||
|
||||
// check the progress callback
|
||||
expect(progressListener.mock.calls.length).toBeGreaterThan(50);
|
||||
|
||||
// The first call should have progress == 0
|
||||
const [firstProgress, totalSteps] = progressListener.mock.calls[0];
|
||||
expect(totalSteps).toBeGreaterThan(3000);
|
||||
expect(firstProgress).toEqual(0);
|
||||
|
||||
for (let i = 1; i < progressListener.mock.calls.length - 1; i++) {
|
||||
const [progress, total] = progressListener.mock.calls[i];
|
||||
expect(total).toEqual(totalSteps);
|
||||
expect(progress).toBeGreaterThan(progressListener.mock.calls[i - 1][0]);
|
||||
expect(progress).toBeLessThanOrEqual(totalSteps);
|
||||
}
|
||||
|
||||
// The final call should have progress == total == -1
|
||||
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("MatrixClient.clearStores", () => {
|
||||
@@ -78,6 +164,7 @@ describe("MatrixClient.clearStores", () => {
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
pickleKey: "testKey",
|
||||
});
|
||||
|
||||
await matrixClient.initRustCrypto();
|
||||
|
||||
@@ -21,12 +21,15 @@ import { MockResponse } from "fetch-mock";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { createHash } from "crypto";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import {
|
||||
createClient,
|
||||
CryptoEvent,
|
||||
DeviceVerification,
|
||||
IContent,
|
||||
ICreateClientOpts,
|
||||
IEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
@@ -41,13 +44,23 @@ import {
|
||||
Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { CRYPTO_BACKENDS, emitPromise, getSyncResponse, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import {
|
||||
awaitDecryption,
|
||||
CRYPTO_BACKENDS,
|
||||
emitPromise,
|
||||
getSyncResponse,
|
||||
InitCrypto,
|
||||
syncPromise,
|
||||
} from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
BOB_ONE_TIME_KEYS,
|
||||
BOB_SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
BOB_SIGNED_TEST_DEVICE_DATA,
|
||||
BOB_TEST_USER_ID,
|
||||
CURVE25519_KEY_BACKUP_DATA,
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
@@ -55,15 +68,25 @@ import {
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
TEST_ROOM_ID,
|
||||
TEST_USER_ID,
|
||||
BOB_ONE_TIME_KEYS,
|
||||
} from "../../test-utils/test-data";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import {
|
||||
bootstrapCrossSigningTestOlmAccount,
|
||||
createOlmSession,
|
||||
encryptGroupSessionKey,
|
||||
encryptMegolmEvent,
|
||||
encryptSecretSend,
|
||||
ToDeviceEvent,
|
||||
} from "./olm-utils";
|
||||
import { KeyBackupInfo } from "../../../src/crypto-api";
|
||||
import { encodeBase64 } from "../../../src/base64";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
jest.useFakeTimers();
|
||||
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
@@ -138,6 +161,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
beforeEach(async () => {
|
||||
// pretend that we have another device, which we will verify
|
||||
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp("m.secret.request")}`),
|
||||
{ ok: false, status: 404 },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
// test with (1) the default verification method list, (2) a custom verification method list.
|
||||
@@ -409,6 +438,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
|
||||
it("can verify another via QR code with an untrusted cross-signing key", async () => {
|
||||
// This is a slightly weird thing to test; if we don't trust the cross-signing key, normally we would
|
||||
// spam out a verification request to all devices rather than targeting a single device. Still, it's
|
||||
// a thing both the Matrix protocol and the js-sdk API support, so we may as well test it.
|
||||
//
|
||||
// Since we don't yet trust the master key, this is a type 0x02 QR code:
|
||||
// "self-verifying in which the current device does not yet trust the master key"
|
||||
//
|
||||
// By the end of it, we should trust the master key.
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
@@ -480,12 +518,51 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
reciprocateQRCodeCallbacks.confirm();
|
||||
await sendToDevicePromise;
|
||||
|
||||
// at this point, on legacy crypto, the master key is already marked as trusted, and the request is "Done".
|
||||
// Rust crypto, on the other hand, waits for the 'done' to arrive from the other side.
|
||||
if (request.phase === VerificationPhase.Done) {
|
||||
// legacy crypto: we're all done
|
||||
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
await verificationPromise;
|
||||
} else {
|
||||
// rust crypto: still in flight
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(request.phase).toEqual(VerificationPhase.Started);
|
||||
}
|
||||
|
||||
// the dummy device replies with its own 'done'
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
// ... and now we're really done.
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(VerificationPhase.Done);
|
||||
const userVerificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
|
||||
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can try to generate a QR code when QR code is not supported", async () => {
|
||||
aliceClient = await startTestClient();
|
||||
// we need cross-signing keys for a QR code verification
|
||||
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
|
||||
await waitForDeviceList();
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can only use SaS
|
||||
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(VerificationPhase.Ready);
|
||||
|
||||
// Alice tries to generate a QR Code but it's unavailable
|
||||
const qrCodeBuffer = await request.generateQRCode();
|
||||
expect(qrCodeBuffer).toBeUndefined();
|
||||
});
|
||||
|
||||
newBackendOnly("can verify another by scanning their QR code", async () => {
|
||||
@@ -667,6 +744,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
expect(toDeviceMessage.code).toEqual("m.user");
|
||||
expect(request.phase).toEqual(VerificationPhase.Cancelled);
|
||||
expect(request.cancellationCode).toEqual("m.user");
|
||||
expect(request.cancellingUserId).toEqual("@alice:localhost");
|
||||
});
|
||||
|
||||
it("can cancel during the SAS phase", async () => {
|
||||
@@ -897,6 +976,488 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
});
|
||||
});
|
||||
|
||||
describe("Incoming verification in a DM", () => {
|
||||
let testOlmAccount: Olm.Account;
|
||||
|
||||
beforeEach(async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
aliceClient.setGlobalErrorOnUnknownDevices(false);
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([BOB_TEST_USER_ID]));
|
||||
await syncPromise(aliceClient);
|
||||
});
|
||||
|
||||
/**
|
||||
* Return a plaintext verification request event from Bob to Alice
|
||||
* @see https://spec.matrix.org/v1.7/client-server-api/#mkeyverificationrequest
|
||||
*/
|
||||
function createVerificationRequestEvent(): IEvent {
|
||||
return {
|
||||
content: {
|
||||
body: "Verification request from Bob to Alice",
|
||||
from_device: "BobDevice",
|
||||
methods: ["m.sas.v1"],
|
||||
msgtype: "m.key.verification.request",
|
||||
to: aliceClient.getUserId()!,
|
||||
},
|
||||
event_id: "$143273582443PhrSn:example.org",
|
||||
origin_server_ts: Date.now(),
|
||||
room_id: TEST_ROOM_ID,
|
||||
sender: "@bob:xyz",
|
||||
type: "m.room.message",
|
||||
unsigned: {
|
||||
age: 1234,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a to-device event from Bob to Alice, sharing the group session key
|
||||
* @param groupSession - group session key to share
|
||||
* @param p2pSession - test Olm session to encrypt the key with
|
||||
*/
|
||||
function encryptGroupSessionKeyForAlice(
|
||||
groupSession: Olm.OutboundGroupSession,
|
||||
p2pSession: Olm.Session,
|
||||
): ToDeviceEvent {
|
||||
return encryptGroupSessionKey({
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
olmAccount: testOlmAccount,
|
||||
p2pSession: p2pSession,
|
||||
groupSession: groupSession,
|
||||
room_id: TEST_ROOM_ID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and encrypt a verification request event
|
||||
* @param groupSession
|
||||
*/
|
||||
function createEncryptedVerificationRequest(groupSession: Olm.OutboundGroupSession): IEvent {
|
||||
const testOlmAccountKeys = JSON.parse(testOlmAccount.identity_keys());
|
||||
return encryptMegolmEvent({
|
||||
senderKey: testOlmAccountKeys.curve25519,
|
||||
groupSession: groupSession,
|
||||
room_id: TEST_ROOM_ID,
|
||||
plaintext: createVerificationRequestEvent(),
|
||||
});
|
||||
}
|
||||
|
||||
it("Verification request not found", async () => {
|
||||
// Expect to not find any verification request
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
expect(request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores old verification requests", async () => {
|
||||
const eventHandler = jest.fn();
|
||||
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
|
||||
|
||||
const verificationRequestEvent = createVerificationRequestEvent();
|
||||
verificationRequestEvent.origin_server_ts -= 1000000;
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, verificationRequestEvent);
|
||||
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// make sure the event has arrived
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
|
||||
|
||||
// check that an event has not been raised, and that the request is not found
|
||||
expect(eventHandler).not.toHaveBeenCalled();
|
||||
expect(
|
||||
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
|
||||
).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("Plaintext verification request from Bob to Alice", async () => {
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
|
||||
|
||||
// Wait for the request to be received
|
||||
const request1 = await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
expect(request1.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request1.isSelfVerification).toBe(false);
|
||||
expect(request1.otherUserId).toBe("@bob:xyz");
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
expect(request?.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request?.isSelfVerification).toBe(false);
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
it("Encrypted verification request from Bob to Alice", async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event, but don't send it yet
|
||||
const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession);
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession));
|
||||
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
const requestEventPromise = emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
|
||||
|
||||
// Send Bob the room keys
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Wait for the request to be decrypted
|
||||
const request1 = await requestEventPromise;
|
||||
expect(request1.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request1.isSelfVerification).toBe(false);
|
||||
expect(request1.otherUserId).toBe("@bob:xyz");
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// Expect to find the verification request received during the sync
|
||||
expect(request?.roomId).toBe(TEST_ROOM_ID);
|
||||
expect(request?.isSelfVerification).toBe(false);
|
||||
expect(request?.otherUserId).toBe("@bob:xyz");
|
||||
});
|
||||
|
||||
newBackendOnly(
|
||||
"If the verification request is not decrypted within 5 minutes, the request is ignored",
|
||||
async () => {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
// make the room_key event, but don't send it yet
|
||||
const toDeviceEvent = encryptGroupSessionKeyForAlice(groupSession, p2pSession);
|
||||
|
||||
// Add verification request from Bob to Alice in the DM between them
|
||||
returnRoomMessageFromSync(TEST_ROOM_ID, createEncryptedVerificationRequest(groupSession));
|
||||
|
||||
// Wait for the sync response to be processed
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const room = aliceClient.getRoom(TEST_ROOM_ID)!;
|
||||
const matrixEvent = room.getLiveTimeline().getEvents()[0];
|
||||
|
||||
// wait for a first attempt at decryption: should fail
|
||||
await awaitDecryption(matrixEvent);
|
||||
expect(matrixEvent.getContent().msgtype).toEqual("m.bad.encrypted");
|
||||
|
||||
// Advance time by 5mins, the verification request should be ignored after that
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
|
||||
// Send Bob the room keys
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
// Wait for the message to be decrypted
|
||||
await awaitDecryption(matrixEvent, { waitOnDecryptionFailure: true });
|
||||
|
||||
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
|
||||
// the request should not be present
|
||||
expect(request).not.toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Secrets are gossiped after verification", () => {
|
||||
// We use a legacy olm session as the existing session.
|
||||
// This will give us access to low level olm functions in order to
|
||||
// simulate a backup key request with proper olm encryption.
|
||||
let testOlmAccount: Olm.Account;
|
||||
const olmDeviceId = "OLM_DEVICE";
|
||||
let usermasterPubKey: string;
|
||||
|
||||
const matchingBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
|
||||
const nonMatchingBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
|
||||
},
|
||||
};
|
||||
|
||||
const unknownAlgorithmBackupInfo: KeyBackupInfo = {
|
||||
algorithm: "m.megolm_backup.foo_bar",
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "EjDwCYkwp1R0i33ctD73Wg2/Og0mOBr066Spjqqaqqo",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
const bootstrapped = bootstrapCrossSigningTestOlmAccount(testOlmAccount, TEST_USER_ID, olmDeviceId, [
|
||||
matchingBackupInfo,
|
||||
nonMatchingBackupInfo,
|
||||
]);
|
||||
|
||||
e2eKeyResponder.addDeviceKeys(bootstrapped.device_keys![TEST_USER_ID]![olmDeviceId]);
|
||||
e2eKeyResponder.addCrossSigningData(bootstrapped);
|
||||
|
||||
usermasterPubKey = Object.values(bootstrapped.master_keys![TEST_USER_ID].keys)[0];
|
||||
|
||||
aliceClient = await startTestClient();
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([TEST_USER_ID]));
|
||||
await syncPromise(aliceClient);
|
||||
// DeviceList has a sleep(5) which we need to make happen
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The client should now know about the olm device
|
||||
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
|
||||
expect(devices.get(TEST_USER_ID)!.keys()).toContain(olmDeviceId);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
aliceClient?.stopClient();
|
||||
testOlmAccount?.free();
|
||||
|
||||
// Allow in-flight things to complete before we tear down the test
|
||||
await jest.runAllTimersAsync();
|
||||
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
newBackendOnly("Should request cross signing keys after verification", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
// The secret must have been requested
|
||||
await requestPromises.get("m.cross_signing.master");
|
||||
await requestPromises.get("m.cross_signing.user_signing");
|
||||
await requestPromises.get("m.cross_signing.self_signing");
|
||||
});
|
||||
|
||||
newBackendOnly("Should accept the backup decryption key gossip if valid", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
const keyBackupIsCached = emitPromise(aliceClient, CryptoEvent.KeyBackupDecryptionKeyCached);
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo);
|
||||
|
||||
await keyBackupIsCached;
|
||||
|
||||
// the backup secret should be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeTruthy();
|
||||
expect(encodeBase64(cachedKey!)).toEqual(BACKUP_DECRYPTION_KEY_BASE64);
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if private key do not match", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, nonMatchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if backup not trusted", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
const infoCopy = Object.assign({}, matchingBackupInfo);
|
||||
delete infoCopy.auth_data.signatures;
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, infoCopy);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept the backup decryption key gossip if backup algorithm unknown", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(
|
||||
requestId!,
|
||||
BACKUP_DECRYPTION_KEY_BASE64,
|
||||
unknownAlgorithmBackupInfo,
|
||||
);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
newBackendOnly("Should not accept an invalid backup decryption key", async () => {
|
||||
const requestPromises = mockSecretRequestAndGetPromises();
|
||||
|
||||
await doInteractiveVerification();
|
||||
|
||||
const requestId = await requestPromises.get("m.megolm_backup.v1");
|
||||
|
||||
await sendBackupGossipAndExpectVersion(requestId!, "InvalidSecret", matchingBackupInfo);
|
||||
|
||||
// We are lacking a way to signal that the secret has been received, so we wait a bit..
|
||||
jest.useRealTimers();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
// the backup secret should not be cached
|
||||
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Common test setup for gossiping secrets.
|
||||
* Creates a peer to peer session, sends the secret, mockup the version API, send the secret back from sync, then await for the backup check.
|
||||
*/
|
||||
async function sendBackupGossipAndExpectVersion(
|
||||
requestId: string,
|
||||
secret: string,
|
||||
expectBackup: KeyBackupInfo,
|
||||
) {
|
||||
const p2pSession = await createOlmSession(testOlmAccount, e2eKeyReceiver);
|
||||
|
||||
const toDeviceEvent = encryptSecretSend({
|
||||
sender: aliceClient.getUserId()!,
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
p2pSession: p2pSession,
|
||||
olmAccount: testOlmAccount,
|
||||
requestId: requestId!,
|
||||
secret: secret,
|
||||
});
|
||||
|
||||
const expectBackupCheck = new Promise((resolve) => {
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/version",
|
||||
(url, request) => {
|
||||
resolve(undefined);
|
||||
return expectBackup;
|
||||
},
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", CURVE25519_KEY_BACKUP_DATA);
|
||||
|
||||
// The dummy device sends the secret
|
||||
returnToDeviceMessageFromSync(toDeviceEvent);
|
||||
|
||||
await expectBackupCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do an interactive verification between alice and the dummy device.
|
||||
*/
|
||||
async function doInteractiveVerification(): Promise<void> {
|
||||
// Do a QR code verification for simplicity
|
||||
|
||||
// Alice sends a m.key.verification.request
|
||||
const [, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, olmDeviceId),
|
||||
]);
|
||||
const transactionId = request.transactionId!;
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, indicating it can show a QR code
|
||||
returnToDeviceMessageFromSync(
|
||||
buildReadyMessage(transactionId, ["m.qr_code.show.v1", "m.reciprocate.v1"], olmDeviceId),
|
||||
);
|
||||
await waitForVerificationRequestChanged(request);
|
||||
|
||||
const currentDeviceKey = e2eKeyReceiver.getSigningKey();
|
||||
// the dummy device shows a QR code
|
||||
const sharedSecret = "SUPERSEKRET";
|
||||
// use mode 0x01, self-verifying in which the current device does trust the master key
|
||||
const mode = 0x01;
|
||||
const qrCodeBuffer = buildQRCode(transactionId, usermasterPubKey, currentDeviceKey, sharedSecret, mode);
|
||||
|
||||
// Alice scans the QR code
|
||||
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
|
||||
const verifier = await request.scanQRCode(qrCodeBuffer);
|
||||
|
||||
await sendToDevicePromise;
|
||||
|
||||
const verificationPromise = verifier.verify();
|
||||
// the dummy device confirms that Alice scanned the QR code, by replying with a done
|
||||
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));
|
||||
|
||||
// Alice also replies with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
|
||||
// The other device should now be verified.
|
||||
const otherDevice = (await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]))
|
||||
.get(TEST_USER_ID)!
|
||||
.get(olmDeviceId);
|
||||
expect(otherDevice?.verified).toEqual(DeviceVerification.Verified);
|
||||
}
|
||||
});
|
||||
|
||||
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
|
||||
const client = createClient({
|
||||
baseUrl: TEST_HOMESERVER_URL,
|
||||
@@ -927,6 +1488,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
|
||||
ev.sender ??= TEST_USER_ID;
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } });
|
||||
}
|
||||
|
||||
function returnRoomMessageFromSync(roomId: string, ev: IEvent): void {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
rooms: {
|
||||
join: {
|
||||
[roomId]: { timeline: { events: [ev] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -947,6 +1519,52 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to add all needed mocks for secret requesting (to-device of type `m.secret.request`).
|
||||
*
|
||||
* The following secrets are mocked: `m.cross_signing.master`, `m.cross_signing.self_signing`,
|
||||
* `m.cross_signing.user_signing`, `m.megolm_backup.v1`.
|
||||
*
|
||||
* @returns a map of secret name to promise that will resolve (with the id of the secret request) when the secret is requested.
|
||||
*/
|
||||
function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const mskRequestDefer = defer<string>();
|
||||
const sskRequestDefer = defer<string>();
|
||||
const uskRequestDefer = defer<string>();
|
||||
const backupKeyRequestDefer = defer<string>();
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
|
||||
(url: string, opts: RequestInit): MockResponse => {
|
||||
const messages = JSON.parse(opts.body as string).messages[TEST_USER_ID];
|
||||
// rust crypto broadcasts to all devices, old crypto to a specific device, take the first one
|
||||
const content = Object.values(messages)[0] as any;
|
||||
if (content.action == "request") {
|
||||
const name = content.name;
|
||||
const requestId = content.request_id;
|
||||
if (name == "m.cross_signing.user_signing") {
|
||||
uskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.master") {
|
||||
mskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.self_signing") {
|
||||
sskRequestDefer.resolve(requestId);
|
||||
} else if (name == "m.megolm_backup.v1") {
|
||||
backupKeyRequestDefer.resolve(requestId);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const promiseMap = new Map<string, Promise<string>>();
|
||||
promiseMap.set("m.cross_signing.master", mskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestDefer.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestDefer.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestDefer.promise);
|
||||
return promiseMap;
|
||||
}
|
||||
|
||||
/** wait for the verification request to emit a 'Change' event */
|
||||
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -991,12 +1609,16 @@ function buildRequestMessage(transactionId: string): { type: string; content: ob
|
||||
};
|
||||
}
|
||||
|
||||
/** build an m.key.verification.ready to-device message originating from the dummy device */
|
||||
function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } {
|
||||
/** build an m.key.verification.ready to-device message originating from the given `fromDevice` (default to `TEST_DEVICE_ID` if not provided) */
|
||||
function buildReadyMessage(
|
||||
transactionId: string,
|
||||
methods: string[],
|
||||
fromDevice?: string,
|
||||
): { type: string; content: object } {
|
||||
return {
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
from_device: fromDevice || TEST_DEVICE_ID,
|
||||
methods: methods,
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
@@ -1094,14 +1716,20 @@ function buildDoneMessage(transactionId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildQRCode(transactionId: string, key1Base64: string, key2Base64: string, sharedSecret: string): Uint8Array {
|
||||
function buildQRCode(
|
||||
transactionId: string,
|
||||
key1Base64: string,
|
||||
key2Base64: string,
|
||||
sharedSecret: string,
|
||||
mode = 0x02,
|
||||
): Uint8Array {
|
||||
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
|
||||
|
||||
const qrCodeBuffer = Buffer.alloc(150); // oversize
|
||||
let idx = 0;
|
||||
idx += qrCodeBuffer.write("MATRIX", idx, "ascii");
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // version
|
||||
idx = qrCodeBuffer.writeUInt8(0x02, idx); // mode
|
||||
idx = qrCodeBuffer.writeUInt8(mode, idx); // mode
|
||||
idx = qrCodeBuffer.writeInt16BE(transactionId.length, idx);
|
||||
idx += qrCodeBuffer.write(transactionId, idx, "ascii");
|
||||
|
||||
|
||||
@@ -194,6 +194,37 @@ describe("MatrixClient events", function () {
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit User events when presence data is absent in first sync", async () => {
|
||||
const MODIFIED_SYNC_DATA: any = structuredClone(SYNC_DATA);
|
||||
delete MODIFIED_SYNC_DATA["presence"];
|
||||
const MODIFIED_NEXT_SYNC_DATA: any = structuredClone(NEXT_SYNC_DATA);
|
||||
MODIFIED_NEXT_SYNC_DATA.presence = {
|
||||
events: [
|
||||
utils.mkPresence({
|
||||
user: "@foo:bar",
|
||||
name: "Foo Bar",
|
||||
presence: "online",
|
||||
}),
|
||||
],
|
||||
};
|
||||
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, MODIFIED_NEXT_SYNC_DATA);
|
||||
let fired = false;
|
||||
client!.on(UserEvent.Presence, function (event, user) {
|
||||
fired = true;
|
||||
expect(user).toBeTruthy();
|
||||
expect(event).toBeTruthy();
|
||||
if (!user || !event) {
|
||||
return;
|
||||
}
|
||||
expect(event.event).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]);
|
||||
expect(user.presence).toEqual(MODIFIED_NEXT_SYNC_DATA.presence.events[0]?.content?.presence);
|
||||
});
|
||||
client!.startClient();
|
||||
await httpBackend!.flushAllExpected();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit Room events", function () {
|
||||
httpBackend!.when("GET", "/sync").respond(200, SYNC_DATA);
|
||||
httpBackend!.when("GET", "/sync").respond(200, NEXT_SYNC_DATA);
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { logger } from "../../src/logger";
|
||||
import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
|
||||
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { Feature, ServerSupport } from "../../src/feature";
|
||||
|
||||
@@ -623,9 +623,7 @@ describe("MatrixClient event timelines", function () {
|
||||
"GET",
|
||||
"/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
@@ -1154,10 +1152,7 @@ describe("MatrixClient event timelines", function () {
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name),
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT_UPDATED.event_id!),
|
||||
)
|
||||
.respond(200, {
|
||||
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
|
||||
@@ -1262,11 +1257,8 @@ describe("MatrixClient event timelines", function () {
|
||||
"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,
|
||||
}),
|
||||
)
|
||||
@@ -1321,11 +1313,7 @@ describe("MatrixClient event timelines", function () {
|
||||
function respondToThread(root: Partial<IEvent>, replies: Partial<IEvent>[]): ExpectedHttpRequest {
|
||||
const request = httpBackend.when(
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(root.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
"?dir=b&limit=1",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(root.event_id!) + "?dir=b",
|
||||
);
|
||||
request.respond(200, function () {
|
||||
return {
|
||||
@@ -1567,7 +1555,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(threadIds).toContain(THREAD2_ROOT.event_id);
|
||||
const [allThreads] = timelineSets!;
|
||||
const timeline = allThreads.getLiveTimeline()!;
|
||||
// Test threads are in chronological order
|
||||
// Test threads are in chronological order (first thread should be first because it has a more recent reply)
|
||||
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD2_ROOT.event_id,
|
||||
@@ -2034,9 +2022,7 @@ describe("MatrixClient event timelines", function () {
|
||||
"GET",
|
||||
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
|
||||
encodeURIComponent(THREAD_ROOT.event_id!) +
|
||||
"/" +
|
||||
encodeURIComponent(THREAD_RELATION_TYPE.name) +
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
|
||||
buildRelationPaginationQuery({ dir: Direction.Backward }),
|
||||
)
|
||||
.respond(200, function () {
|
||||
return {
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
describe("joinRoom", function () {
|
||||
it("should no-op if you've already joined a room", function () {
|
||||
it("should no-op given the ID of a room you've already joined", async () => {
|
||||
const roomId = "!foo:bar";
|
||||
const room = new Room(roomId, client, userId);
|
||||
client.fetchRoomEvent = () =>
|
||||
@@ -168,8 +168,32 @@ describe("MatrixClient", function () {
|
||||
]);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
store.storeRoom(room);
|
||||
client.joinRoom(roomId);
|
||||
|
||||
const joinPromise = client.joinRoom(roomId);
|
||||
httpBackend.verifyNoOutstandingRequests();
|
||||
expect(await joinPromise).toBe(room);
|
||||
});
|
||||
|
||||
it("should no-op given the alias of a room you've already joined", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const roomAlias = "#my-fancy-room:server";
|
||||
const room = new Room(roomId, client, userId);
|
||||
room.addLiveEvents([
|
||||
utils.mkMembership({
|
||||
user: userId,
|
||||
room: roomId,
|
||||
mship: "join",
|
||||
event: true,
|
||||
}),
|
||||
]);
|
||||
store.storeRoom(room);
|
||||
|
||||
// The method makes a request to resolve the alias
|
||||
httpBackend.when("POST", "/join/" + encodeURIComponent(roomAlias)).respond(200, { room_id: roomId });
|
||||
|
||||
const joinPromise = client.joinRoom(roomAlias);
|
||||
await httpBackend.flushAllExpected();
|
||||
expect(await joinPromise).toBe(room);
|
||||
});
|
||||
|
||||
it("should send request to inviteSignUrl if specified", async () => {
|
||||
@@ -1180,51 +1204,20 @@ describe("MatrixClient", function () {
|
||||
|
||||
describe("requestLoginToken", () => {
|
||||
it("should hit the expected API endpoint with UIA", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
|
||||
const response = {};
|
||||
const uiaData = {};
|
||||
const prom = client.requestLoginToken(uiaData);
|
||||
httpBackend
|
||||
.when("POST", "/unstable/org.matrix.msc3882/login/get_token", { auth: uiaData })
|
||||
.respond(200, response);
|
||||
httpBackend.when("POST", "/v1/login/get_token", { auth: uiaData }).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
expect(await prom).toStrictEqual(response);
|
||||
});
|
||||
|
||||
it("should hit the expected API endpoint without UIA", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
|
||||
const response = { login_token: "xyz", expires_in_ms: 5000 };
|
||||
const prom = client.requestLoginToken();
|
||||
httpBackend.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
|
||||
httpBackend.when("POST", "/v1/login/get_token", {}).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
// check that expires_in has been populated for compatibility with r0
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
|
||||
});
|
||||
|
||||
it("should hit the r1 endpoint when capability is disabled", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/capabilities")
|
||||
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: false } } });
|
||||
const response = { login_token: "xyz", expires_in_ms: 5000 };
|
||||
const prom = client.requestLoginToken();
|
||||
httpBackend.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
// check that expires_in has been populated for compatibility with r0
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
|
||||
});
|
||||
|
||||
it("should hit the r0 endpoint for fallback", async () => {
|
||||
httpBackend.when("GET", "/capabilities").respond(200, {});
|
||||
const response = { login_token: "xyz", expires_in: 5 };
|
||||
const prom = client.requestLoginToken();
|
||||
httpBackend.when("POST", "/unstable/org.matrix.msc3882/login/token", {}).respond(200, response);
|
||||
await httpBackend.flush("");
|
||||
// check that expires_in has been populated for compatibility with r1
|
||||
expect(await prom).toStrictEqual({ ...response, expires_in_ms: 5000 });
|
||||
expect(await prom).toStrictEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
Copyright 2023 Holi Moli GmbH
|
||||
|
||||
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 fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MatrixClient, ClientEvent, createClient, SyncState } from "../../src";
|
||||
|
||||
const makeQueryablePromise = <T = void>(promise: Promise<T>) => {
|
||||
let resolved = false;
|
||||
let rejected = false;
|
||||
|
||||
// Observe the promise, saving the fulfillment in a closure scope.
|
||||
const newPromise = promise.then(
|
||||
(value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
rejected = true;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
const isFulfilled = () => {
|
||||
return resolved || rejected;
|
||||
};
|
||||
const isResolved = () => {
|
||||
return resolved;
|
||||
};
|
||||
const isRejected = () => {
|
||||
return rejected;
|
||||
};
|
||||
return { promise: newPromise, isFulfilled, isResolved, isRejected };
|
||||
};
|
||||
|
||||
const queryablePromise = <T = void>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: any) => void;
|
||||
|
||||
const promise = makeQueryablePromise<T>(
|
||||
new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
}),
|
||||
);
|
||||
|
||||
return { resolve, reject, ...promise };
|
||||
};
|
||||
|
||||
describe("MatrixClient syncing errors", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
const unknownTokenErrorData = {
|
||||
status: 401,
|
||||
body: {
|
||||
errcode: "M_UNKNOWN_TOKEN",
|
||||
error: "Invalid access token passed.",
|
||||
soft_logout: false,
|
||||
},
|
||||
};
|
||||
let client: MatrixClient | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createClient({
|
||||
baseUrl: "http://tocal.test.server",
|
||||
userId: selfUserId,
|
||||
accessToken: selfAccessToken,
|
||||
deviceId: "myDevice",
|
||||
});
|
||||
});
|
||||
|
||||
it("should retry, until errors are solved.", async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.config.overwriteRoutes = false;
|
||||
fetchMock
|
||||
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
|
||||
.getOnce("end:versions", 429) // second version check fails with 429 triggering another retry
|
||||
.get("end:versions", {}) // further version checks succeed
|
||||
.getOnce("end:pushrules/", 429) // first pushrules check fails starting retry
|
||||
.get("end:pushrules/", {}) // further pushrules check succeed
|
||||
.catch({}); // all other calls succeed
|
||||
|
||||
const syncEvents = Array.from({ length: 5 }, queryablePromise<SyncState>);
|
||||
|
||||
client!.on(ClientEvent.Sync, (state: SyncState, lastState: SyncState | null) => {
|
||||
let i = 0;
|
||||
for (; i < syncEvents.length && syncEvents[i].isFulfilled(); i++) {
|
||||
// find index of first unfulfilled promise
|
||||
}
|
||||
syncEvents[i].resolve(state);
|
||||
});
|
||||
|
||||
await client!.startClient();
|
||||
expect(await syncEvents[0].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[1].promise).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
|
||||
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
|
||||
});
|
||||
|
||||
it("should stop sync keep alive when client is stopped.", async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.config.overwriteRoutes = false;
|
||||
fetchMock
|
||||
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
|
||||
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401
|
||||
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse
|
||||
.post("end:logout", unknownTokenErrorData) // just to keep up a consistent scenario. Does not have a real effect for this testcase
|
||||
.post("end:filter", 401); // just to keep up a consistent scenario. Does not have a real effect for this testcase
|
||||
|
||||
const firstSyncEvent = queryablePromise<SyncState>();
|
||||
const secondSyncEvent = queryablePromise<SyncState>();
|
||||
client!.on(ClientEvent.Sync, (state: SyncState, lastState: SyncState | null) => {
|
||||
if (firstSyncEvent.isFulfilled()) secondSyncEvent.resolve(state);
|
||||
firstSyncEvent.resolve(state);
|
||||
});
|
||||
|
||||
await client!.startClient();
|
||||
const logoutDone = queryablePromise();
|
||||
client!
|
||||
.logout(true)
|
||||
.then(() => {
|
||||
logoutDone.resolve();
|
||||
})
|
||||
.catch((e) => {
|
||||
logoutDone.resolve();
|
||||
});
|
||||
|
||||
const syntState = await firstSyncEvent.promise;
|
||||
expect(syntState).toBe(SyncState.Error);
|
||||
jest.runAllTimers(); // this will skip forward to trigger the keepAlive
|
||||
|
||||
jest.useRealTimers(); // we need real timer for the setTimout below to work
|
||||
|
||||
const timeoutPromise = makeQueryablePromise(new Promise<void>((res) => setTimeout(res, 1)));
|
||||
|
||||
await Promise.race([secondSyncEvent.promise, timeoutPromise.promise]);
|
||||
// when syncing stopped, then the secondSyncEvent will never happen and the promise will not be resolved,
|
||||
/// so the timeoutPromise will be resolved instead
|
||||
expect(timeoutPromise.isFulfilled()).toBe(true);
|
||||
expect(secondSyncEvent.isFulfilled()).toBe(false);
|
||||
|
||||
await logoutDone.promise; // wait for the logout to finish to prevent processing and logging after the test is done.
|
||||
});
|
||||
});
|
||||
@@ -28,32 +28,70 @@ import {
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
fixNotificationCountOnDecryption,
|
||||
} from "../../src";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { mkThread } from "../test-utils/thread";
|
||||
import { SyncState } from "../../src/sync";
|
||||
|
||||
const userA = "@alice:localhost";
|
||||
const userB = "@bob:localhost";
|
||||
const selfUserId = userA;
|
||||
const selfAccessToken = "aseukfgwef";
|
||||
|
||||
function 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];
|
||||
}
|
||||
|
||||
describe("Notification count fixing", () => {
|
||||
let client: MatrixClient | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
[client] = setupTestClient();
|
||||
});
|
||||
|
||||
it("doesn't increment notification count for events that can't be found in a room", async () => {
|
||||
const roomId = "!room:localhost";
|
||||
|
||||
client!.startClient({ threadSupport: true });
|
||||
const room = new Room(roomId, client!, selfUserId);
|
||||
jest.spyOn(client!, "getRoom").mockImplementation((id) => (id === roomId ? room : null));
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: roomId,
|
||||
type: "m.reaction",
|
||||
event_id: "$foo",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$foo",
|
||||
key: "x",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(event, "getPushActions").mockReturnValue({
|
||||
notify: true,
|
||||
tweaks: {},
|
||||
});
|
||||
|
||||
fixNotificationCountOnDecryption(client!, event);
|
||||
|
||||
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import { logger } from "../src/logger";
|
||||
// try to load the olm library.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
global.Olm = require("@matrix-org/olm");
|
||||
globalThis.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
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 { MockOptionsMethodPut } from "fetch-mock";
|
||||
|
||||
import { ISyncResponder } from "./SyncResponder";
|
||||
|
||||
/**
|
||||
* An object which intercepts `account_data` get and set requests via fetch-mock.
|
||||
*/
|
||||
export class AccountDataAccumulator {
|
||||
/**
|
||||
* The account data events to be returned by the sync.
|
||||
* Will be updated when fetchMock intercepts calls to PUT `/_matrix/client/v3/user/:userId/account_data/`.
|
||||
* Will be used by `sendSyncResponseWithUpdatedAccountData`
|
||||
*/
|
||||
public accountDataEvents: Map<String, any> = new Map();
|
||||
|
||||
/**
|
||||
* Intercept requests to set a particular type of account data.
|
||||
*
|
||||
* Once it is set, its data is stored (for future return by `interceptGetAccountData` etc) and the resolved promise is
|
||||
* resolved.
|
||||
*
|
||||
* @param accountDataType - type of account data to be intercepted
|
||||
* @param opts - options to pass to fetchMock
|
||||
* @returns a Promise which will resolve (with the content of the account data) once it is set.
|
||||
*/
|
||||
public interceptSetAccountData(accountDataType: string, opts?: MockOptionsMethodPut): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
// Called when the cross signing key is uploaded
|
||||
fetchMock.put(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/${accountDataType}`,
|
||||
(url: string, options: RequestInit) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const type = url.split("/").pop();
|
||||
// update account data for sync response
|
||||
this.accountDataEvents.set(type!, content);
|
||||
resolve(content);
|
||||
return {};
|
||||
},
|
||||
opts,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept all requests to get account data
|
||||
*/
|
||||
public interceptGetAccountData(): void {
|
||||
fetchMock.get(
|
||||
`express:/_matrix/client/v3/user/:userId/account_data/:type`,
|
||||
(url) => {
|
||||
const type = url.split("/").pop();
|
||||
const existing = this.accountDataEvents.get(type!);
|
||||
if (existing) {
|
||||
// return it
|
||||
return {
|
||||
status: 200,
|
||||
body: existing,
|
||||
};
|
||||
} else {
|
||||
// 404
|
||||
return {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
};
|
||||
}
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sync response the current account data events.
|
||||
*/
|
||||
public sendSyncResponseWithUpdatedAccountData(syncResponder: ISyncResponder): void {
|
||||
try {
|
||||
syncResponder.sendOrQueueSyncResponse({
|
||||
next_batch: 1,
|
||||
account_data: {
|
||||
events: Array.from(this.accountDataEvents, ([type, content]) => ({
|
||||
type: type,
|
||||
content: content,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Might fail with "Cannot queue more than one /sync response" if called too often.
|
||||
// It's ok if it fails here, the sync response is cumulative and will contain
|
||||
// the latest account data.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,21 @@ import { KeyBackupInfo } from "../../src/crypto-api";
|
||||
* @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: ["v1.1"] });
|
||||
fetchMock.getOnce(new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(), {});
|
||||
fetchMock.postOnce(new URL("/_matrix/client/v3/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
|
||||
filter_id: "fid",
|
||||
});
|
||||
fetchMock.getOnce(
|
||||
new URL("/_matrix/client/versions", homeserverUrl).toString(),
|
||||
{ versions: ["v1.1"] },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
fetchMock.getOnce(
|
||||
new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(),
|
||||
{},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
fetchMock.postOnce(
|
||||
new URL("/_matrix/client/v3/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(),
|
||||
{ filter_id: "fid" },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -315,6 +315,7 @@ export interface IMessageOpts {
|
||||
event?: boolean;
|
||||
relatesTo?: IEventRelation;
|
||||
ts?: number;
|
||||
unsigned?: IUnsigned;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,8 +538,6 @@ export async function awaitDecryption(
|
||||
});
|
||||
}
|
||||
|
||||
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
|
||||
|
||||
export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
|
||||
app_display_name: "app",
|
||||
app_id: "123",
|
||||
@@ -561,3 +560,25 @@ CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto();
|
||||
if (global.Olm) {
|
||||
CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initCrypto();
|
||||
}
|
||||
|
||||
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise((r) => e.once(k, r));
|
||||
|
||||
/**
|
||||
* Advance the fake timers in a loop until the given promise resolves or rejects.
|
||||
*
|
||||
* Returns the result of the promise.
|
||||
*
|
||||
* This can be useful when there are multiple steps in the code which require an iteration of the event loop.
|
||||
*/
|
||||
export async function advanceTimersUntil<T>(promise: Promise<T>): Promise<T> {
|
||||
let resolved = false;
|
||||
promise.finally(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
while (!resolved) {
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
## Dump of libolm indexeddb cryptostore
|
||||
|
||||
This directory contains a dump of a real indexeddb store from a session using
|
||||
libolm crypto.
|
||||
|
||||
The corresponding pickle key is `+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o`.
|
||||
|
||||
It was created by pasting the following into the browser console:
|
||||
|
||||
```javascript
|
||||
async function exportIndexedDb(name) {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const dbReq = indexedDB.open(name);
|
||||
dbReq.onerror = reject;
|
||||
dbReq.onsuccess = () => resolve(dbReq.result);
|
||||
});
|
||||
|
||||
const storeNames = db.objectStoreNames;
|
||||
const exports = {};
|
||||
for (const store of storeNames) {
|
||||
exports[store] = [];
|
||||
const txn = db.transaction(store, "readonly");
|
||||
const objectStore = txn.objectStore(store);
|
||||
await new Promise((resolve, reject) => {
|
||||
const cursorReq = objectStore.openCursor();
|
||||
cursorReq.onerror = reject;
|
||||
cursorReq.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const entry = { value: cursor.value };
|
||||
if (!objectStore.keyPath) {
|
||||
entry.key = cursor.key;
|
||||
}
|
||||
exports[store].push(entry);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
window.saveAs(
|
||||
new Blob([JSON.stringify(await exportIndexedDb("matrix-js-sdk:crypto"), null, 2)], {
|
||||
type: "application/json;charset=utf-8",
|
||||
}),
|
||||
"dump.json",
|
||||
);
|
||||
```
|
||||
|
||||
The pickle key is extracted via `mxMatrixClientPeg.get().crypto.olmDevice.pickleKey`.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
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 { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Populate an IndexedDB store with the test data from this directory.
|
||||
*
|
||||
* @param name - Name of the IndexedDB database to create.
|
||||
*/
|
||||
export async function populateStore(name: string): Promise<IDBDatabase> {
|
||||
const req = indexedDB.open(name, 11);
|
||||
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
req.onupgradeneeded = (ev): void => {
|
||||
const db = req.result;
|
||||
const oldVersion = ev.oldVersion;
|
||||
upgradeDatabase(oldVersion, db);
|
||||
};
|
||||
|
||||
req.onerror = (ev): void => {
|
||||
reject(req.error);
|
||||
};
|
||||
|
||||
req.onsuccess = (): void => {
|
||||
const db = req.result;
|
||||
resolve(db);
|
||||
};
|
||||
});
|
||||
|
||||
await importData(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Create the schema for the indexed db store */
|
||||
function upgradeDatabase(oldVersion: number, db: IDBDatabase) {
|
||||
if (oldVersion < 1) {
|
||||
const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
|
||||
outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
|
||||
outgoingRoomKeyRequestsStore.createIndex("state", "state");
|
||||
}
|
||||
|
||||
if (oldVersion < 2) {
|
||||
db.createObjectStore("account");
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"] });
|
||||
sessionsStore.createIndex("deviceKey", "deviceKey");
|
||||
}
|
||||
|
||||
if (oldVersion < 4) {
|
||||
db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 5) {
|
||||
db.createObjectStore("device_data");
|
||||
}
|
||||
|
||||
if (oldVersion < 6) {
|
||||
db.createObjectStore("rooms");
|
||||
}
|
||||
|
||||
if (oldVersion < 7) {
|
||||
db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 8) {
|
||||
db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 9) {
|
||||
const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"] });
|
||||
problemsStore.createIndex("deviceKey", "deviceKey");
|
||||
|
||||
db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 10) {
|
||||
db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"] });
|
||||
}
|
||||
|
||||
if (oldVersion < 11) {
|
||||
db.createObjectStore("parked_shared_history", { keyPath: ["roomId"] });
|
||||
}
|
||||
}
|
||||
|
||||
async function importData(db: IDBDatabase) {
|
||||
const path = resolve("spec/test-utils/test_indexeddb_cryptostore_dump/dump.json");
|
||||
const json: Record<string, Array<{ key?: any; value: any }>> = JSON.parse(
|
||||
await readFile(path, { encoding: "utf8" }),
|
||||
);
|
||||
|
||||
for (const [storeName, data] of Object.entries(json)) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const store = db.transaction(storeName, "readwrite").objectStore(storeName);
|
||||
|
||||
function putEntry(idx: number) {
|
||||
if (idx >= data.length) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { key, value } = data[idx];
|
||||
try {
|
||||
const putReq = store.put(value, key);
|
||||
putReq.onsuccess = (_) => putEntry(idx + 1);
|
||||
putReq.onerror = (_) => reject(putReq.error);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Error populating '${storeName}' with key ${JSON.stringify(key)}, value ${JSON.stringify(
|
||||
value,
|
||||
)}: ${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
putEntry(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -161,3 +161,23 @@ export const mkThread = ({
|
||||
|
||||
return { thread, rootEvent, events };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a thread, and make sure the events are added to the thread and the
|
||||
* room's timeline as if they came in via sync.
|
||||
*
|
||||
* Note that mkThread doesn't actually add the events properly to the room.
|
||||
*/
|
||||
export const populateThread = ({
|
||||
room,
|
||||
client,
|
||||
authorId,
|
||||
participantUserIds,
|
||||
length = 2,
|
||||
ts = 1,
|
||||
}: MakeThreadProps): MakeThreadResult => {
|
||||
const ret = mkThread({ room, client, authorId, participantUserIds, length, ts });
|
||||
ret.thread.initialEventsFetched = true;
|
||||
room.addLiveEvents(ret.events);
|
||||
return ret;
|
||||
};
|
||||
|
||||
@@ -269,7 +269,11 @@ export class MockRTCRtpTransceiver {
|
||||
}
|
||||
|
||||
export class MockMediaStreamTrack {
|
||||
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {}
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly kind: "audio" | "video",
|
||||
public enabled = true,
|
||||
) {}
|
||||
|
||||
public stop = jest.fn<void, []>();
|
||||
|
||||
@@ -306,7 +310,10 @@ export class MockMediaStreamTrack {
|
||||
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
|
||||
// implementation
|
||||
export class MockMediaStream {
|
||||
constructor(public id: string, private tracks: MockMediaStreamTrack[] = []) {}
|
||||
constructor(
|
||||
public id: string,
|
||||
private tracks: MockMediaStreamTrack[] = [],
|
||||
) {}
|
||||
|
||||
public listeners: [string, (...args: any[]) => any][] = [];
|
||||
public isStopped = false;
|
||||
@@ -435,7 +442,11 @@ type EmittedEventMap = CallEventHandlerEventHandlerMap &
|
||||
export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
|
||||
public mediaHandler = new MockMediaHandler();
|
||||
|
||||
constructor(public userId: string, public deviceId: string, public sessionId: string) {
|
||||
constructor(
|
||||
public userId: string,
|
||||
public deviceId: string,
|
||||
public sessionId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -502,7 +513,10 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
|
||||
}
|
||||
|
||||
export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
|
||||
constructor(public roomId: string, public groupCallId?: string) {
|
||||
constructor(
|
||||
public roomId: string,
|
||||
public groupCallId?: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -550,7 +564,11 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
|
||||
}
|
||||
|
||||
export class MockCallFeed {
|
||||
constructor(public userId: string, public deviceId: string | undefined, public stream: MockMediaStream) {}
|
||||
constructor(
|
||||
public userId: string,
|
||||
public deviceId: string | undefined,
|
||||
public stream: MockMediaStream,
|
||||
) {}
|
||||
|
||||
public measureVolumeActivity(val: boolean) {}
|
||||
public dispose() {}
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("AutoDiscovery", function () {
|
||||
function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
|
||||
not_matrix_versions: ["v1.1"],
|
||||
not_matrix_versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -388,7 +388,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -428,7 +428,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -469,7 +469,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -515,7 +515,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -560,7 +560,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
@@ -606,7 +606,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -653,7 +653,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
@@ -697,7 +697,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
@@ -747,7 +747,7 @@ describe("AutoDiscovery", function () {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["v1.1"],
|
||||
versions: ["v1.5"],
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/identity/v2")
|
||||
@@ -910,7 +910,7 @@ describe("AutoDiscovery", function () {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetBehavior();
|
||||
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.1"] });
|
||||
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.5"] });
|
||||
|
||||
fetchMock.get("https://example.org/.well-known/matrix/client", {
|
||||
"m.homeserver": {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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 { TextEncoder, TextDecoder } from "util";
|
||||
import NodeBuffer from "node:buffer";
|
||||
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64, encodeUnpaddedBase64Url } from "../../src/base64";
|
||||
|
||||
describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
|
||||
let origBuffer = Buffer;
|
||||
|
||||
beforeAll(() => {
|
||||
if (env === "browser") {
|
||||
origBuffer = Buffer;
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Buffer = undefined;
|
||||
|
||||
global.atob = NodeBuffer.atob;
|
||||
global.btoa = NodeBuffer.btoa;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
Buffer = origBuffer;
|
||||
// @ts-ignore
|
||||
global.atob = undefined;
|
||||
// @ts-ignore
|
||||
global.btoa = undefined;
|
||||
});
|
||||
|
||||
it("Should decode properly encoded data", () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ="));
|
||||
|
||||
expect(decoded).toStrictEqual("encoding hello world");
|
||||
});
|
||||
|
||||
it("Should encode unpadded URL-safe base64", () => {
|
||||
const toEncode = "?????";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const encoded = encodeUnpaddedBase64Url(data);
|
||||
expect(encoded).toEqual("Pz8_Pz8");
|
||||
});
|
||||
|
||||
it("Should decode URL-safe base64", () => {
|
||||
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
|
||||
|
||||
expect(decoded).toStrictEqual("?????");
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const paddedEncoded = encodeBase64(data);
|
||||
const unpaddedEncoded = encodeUnpaddedBase64(data);
|
||||
|
||||
expect(paddedEncoded).not.toEqual(unpaddedEncoded);
|
||||
|
||||
const padding = paddedEncoded.charAt(paddedEncoded.length - 1);
|
||||
expect(padding).toStrictEqual("=");
|
||||
});
|
||||
|
||||
it("Decode should be indifferent to padding", () => {
|
||||
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
|
||||
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";
|
||||
|
||||
const decodedPad = decodeBase64(withPadding);
|
||||
const decodedNoPad = decodeBase64(withoutPadding);
|
||||
|
||||
expect(decodedPad).toStrictEqual(decodedNoPad);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,21 @@ describe("ContentRepo", function () {
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow redirects when requested on download URLs", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, false, true)).toEqual(
|
||||
baseUrl + "/_matrix/media/v3/download/server.name/resourceid?allow_redirect=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow redirects when requested on thumbnail URLs", function () {
|
||||
const mxcUri = "mxc://server.name/resourceid";
|
||||
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 32, "scale", false, true)).toEqual(
|
||||
baseUrl +
|
||||
"/_matrix/media/v3/thumbnail/server.name/resourceid?width=32&height=32&method=scale&allow_redirect=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the empty string for null input", function () {
|
||||
expect(getHttpUriForMxc(null as any, "")).toEqual("");
|
||||
});
|
||||
|
||||
+18
-24
@@ -115,6 +115,16 @@ describe("Crypto", function () {
|
||||
expect(Crypto.getOlmVersion()[0]).toEqual(3);
|
||||
});
|
||||
|
||||
it("getVersion() should return the current version of the olm library", async () => {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
await client.initCrypto();
|
||||
|
||||
const olmVersionTuple = Crypto.getOlmVersion();
|
||||
expect(client.getCrypto()?.getVersion()).toBe(
|
||||
`Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("encrypted events", function () {
|
||||
it("provides encryption information for events from unverified senders", async function () {
|
||||
const client = new TestClient("@alice:example.com", "deviceid").client;
|
||||
@@ -258,7 +268,7 @@ describe("Crypto", function () {
|
||||
const event = await buildEncryptedEvent();
|
||||
expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({
|
||||
shieldColour: EventShieldColour.RED,
|
||||
shieldReason: EventShieldReason.UNVERIFIED_IDENTITY,
|
||||
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,7 +356,6 @@ describe("Crypto", function () {
|
||||
|
||||
let crypto: Crypto;
|
||||
let mockBaseApis: MatrixClient;
|
||||
let mockRoomList: RoomList;
|
||||
|
||||
let fakeEmitter: EventEmitter;
|
||||
|
||||
@@ -380,19 +389,10 @@ describe("Crypto", function () {
|
||||
isGuest: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
mockRoomList = {} as unknown as RoomList;
|
||||
|
||||
fakeEmitter = new EventEmitter();
|
||||
|
||||
crypto = new Crypto(
|
||||
mockBaseApis,
|
||||
"@alice:home.server",
|
||||
"FLIBBLE",
|
||||
clientStore,
|
||||
cryptoStore,
|
||||
mockRoomList,
|
||||
[],
|
||||
);
|
||||
crypto = new Crypto(mockBaseApis, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
|
||||
crypto.registerEventHandlers(fakeEmitter as any);
|
||||
await crypto.init();
|
||||
});
|
||||
@@ -1106,7 +1106,7 @@ describe("Crypto", function () {
|
||||
|
||||
describe("Secret storage", function () {
|
||||
it("creates secret storage even if there is no keyInfo", async function () {
|
||||
jest.spyOn(logger, "log").mockImplementation(() => {});
|
||||
jest.spyOn(logger, "debug").mockImplementation(() => {});
|
||||
jest.setTimeout(10000);
|
||||
const client = new TestClient("@a:example.com", "dev").client;
|
||||
await client.initCrypto();
|
||||
@@ -1263,7 +1263,7 @@ describe("Crypto", function () {
|
||||
({
|
||||
init_with_private_key: jest.fn(),
|
||||
free,
|
||||
} as unknown as PkDecryption),
|
||||
}) as unknown as PkDecryption,
|
||||
);
|
||||
client.client.checkSecretStoragePrivateKey(new Uint8Array(), "");
|
||||
expect(free).toHaveBeenCalled();
|
||||
@@ -1289,7 +1289,7 @@ describe("Crypto", function () {
|
||||
({
|
||||
init_with_seed: jest.fn(),
|
||||
free,
|
||||
} as unknown as PkSigning),
|
||||
}) as unknown as PkSigning,
|
||||
);
|
||||
client.client.checkCrossSigningPrivateKey(new Uint8Array(), "");
|
||||
expect(free).toHaveBeenCalled();
|
||||
@@ -1331,15 +1331,9 @@ describe("Crypto", function () {
|
||||
setRoomEncryption: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as RoomList;
|
||||
|
||||
crypto = new Crypto(
|
||||
mockClient,
|
||||
"@alice:home.server",
|
||||
"FLIBBLE",
|
||||
clientStore,
|
||||
cryptoStore,
|
||||
mockRoomList,
|
||||
[],
|
||||
);
|
||||
crypto = new Crypto(mockClient, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
|
||||
// @ts-ignore we are injecting a mock into a private property
|
||||
crypto.roomList = mockRoomList;
|
||||
});
|
||||
|
||||
it("should set the algorithm if called for a known room", async () => {
|
||||
|
||||
@@ -215,6 +215,36 @@ describe("MegolmBackup", function () {
|
||||
jest.spyOn(global, "setTimeout").mockRestore();
|
||||
});
|
||||
|
||||
test("fail if crypto not enabled", async () => {
|
||||
const client = makeTestClient(cryptoStore);
|
||||
const data = {
|
||||
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
|
||||
version: "1",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
await expect(client.restoreKeyBackupWithSecretStorage(data)).rejects.toThrow(
|
||||
"End-to-end encryption disabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("fail if given backup has no version", async () => {
|
||||
const client = makeTestClient(cryptoStore);
|
||||
await client.initCrypto();
|
||||
const data = {
|
||||
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
};
|
||||
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
await client.getCrypto()!.storeSessionBackupPrivateKey(key, "1");
|
||||
await expect(client.restoreKeyBackupWithCache(undefined, undefined, data)).rejects.toThrow(
|
||||
"Backup version must be defined",
|
||||
);
|
||||
});
|
||||
|
||||
it("automatically calls the key back up", function () {
|
||||
const groupSession = new Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
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 { TextEncoder, TextDecoder } from "util";
|
||||
|
||||
import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../../src/common-crypto/base64";
|
||||
|
||||
describe("Crypto Base64 encoding", () => {
|
||||
it("Should decode properly encoded data", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const encoded = encodeBase64(new TextEncoder().encode(toEncode));
|
||||
const decoded = new TextDecoder().decode(decodeBase64(encoded));
|
||||
|
||||
expect(decoded).toStrictEqual(toEncode);
|
||||
});
|
||||
|
||||
it("Encode unpadded should not have padding", async () => {
|
||||
const toEncode = "encoding hello world";
|
||||
const data = new TextEncoder().encode(toEncode);
|
||||
|
||||
const paddedEncoded = encodeBase64(data);
|
||||
const unpaddedEncoded = encodeUnpaddedBase64(data);
|
||||
|
||||
expect(paddedEncoded).not.toEqual(unpaddedEncoded);
|
||||
|
||||
const padding = paddedEncoded.charAt(paddedEncoded.length - 1);
|
||||
expect(padding).toStrictEqual("=");
|
||||
});
|
||||
|
||||
it("Decode should be indifferent to padding", async () => {
|
||||
const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ=";
|
||||
const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ";
|
||||
|
||||
const decodedPad = decodeBase64(withPadding);
|
||||
const decodedNoPad = decodeBase64(withoutPadding);
|
||||
|
||||
expect(decodedPad).toStrictEqual(decodedNoPad);
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ describe("Cross Signing", function () {
|
||||
});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T>() => ({} as T);
|
||||
alice.getAccountDataFromServer = async <T>() => ({}) as T;
|
||||
// set Alice's cross-signing key
|
||||
await alice.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (func) => {
|
||||
@@ -146,7 +146,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);
|
||||
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({}) as T;
|
||||
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
|
||||
await func({});
|
||||
};
|
||||
|
||||
@@ -33,12 +33,10 @@ export async function resetCrossSigningKeys(
|
||||
|
||||
export async function createSecretStorageKey(): Promise<IRecoveryKey> {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
const storagePublicKey = decryption.generate_key();
|
||||
decryption.generate_key();
|
||||
const storagePrivateKey = decryption.get_private_key();
|
||||
decryption.free();
|
||||
return {
|
||||
// `pubkey` not used anymore with symmetric 4S
|
||||
keyInfo: { pubkey: storagePublicKey, key: undefined! },
|
||||
privateKey: storagePrivateKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ 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";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string; deviceId: string },
|
||||
@@ -189,10 +190,7 @@ describe("Secrets", function () {
|
||||
};
|
||||
resetCrossSigningKeys(alice);
|
||||
|
||||
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, {
|
||||
pubkey: undefined,
|
||||
key: undefined,
|
||||
});
|
||||
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { key });
|
||||
// we don't await on this because it waits for the event to come down the sync
|
||||
// which won't happen in the test setup
|
||||
alice.setDefaultSecretStorageKeyId(newKeyId);
|
||||
@@ -275,13 +273,13 @@ describe("Secrets", function () {
|
||||
|
||||
describe("bootstrap", function () {
|
||||
// keys used in some of the tests
|
||||
const XSK = new Uint8Array(olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
|
||||
const XSK = new Uint8Array(decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="));
|
||||
const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0";
|
||||
const USK = new Uint8Array(olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
|
||||
const USK = new Uint8Array(decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="));
|
||||
const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU";
|
||||
const SSK = new Uint8Array(olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
|
||||
const SSK = new Uint8Array(decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="));
|
||||
const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q";
|
||||
const SSSSKey = new Uint8Array(olmlib.decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
|
||||
const SSSSKey = new Uint8Array(decodeBase64("XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0="));
|
||||
|
||||
it("bootstraps when no storage or cross-signing keys locally", async function () {
|
||||
const key = new Uint8Array(16);
|
||||
@@ -334,7 +332,6 @@ describe("Secrets", function () {
|
||||
|
||||
it("bootstraps when cross-signing keys in secret storage", async function () {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
const storagePublicKey = decryption.generate_key();
|
||||
const storagePrivateKey = decryption.get_private_key();
|
||||
|
||||
const bob: MatrixClient = await makeTestClient(
|
||||
@@ -377,8 +374,6 @@ describe("Secrets", function () {
|
||||
});
|
||||
await bob.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => ({
|
||||
// `pubkey` not used anymore with symmetric 4S
|
||||
keyInfo: { pubkey: storagePublicKey },
|
||||
privateKey: storagePrivateKey,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 "jest-localstorage-mock";
|
||||
import { IndexedDBCryptoStore, LocalStorageCryptoStore, MemoryCryptoStore } from "../../../../src";
|
||||
import { CryptoStore, MigrationState, SESSION_BATCH_SIZE } from "../../../../src/crypto/store/base";
|
||||
|
||||
describe.each([
|
||||
["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
|
||||
["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
|
||||
["MemoryCryptoStore", () => new MemoryCryptoStore()],
|
||||
])("CryptoStore tests for %s", function (name, dbFactory) {
|
||||
let store: CryptoStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = dbFactory();
|
||||
});
|
||||
|
||||
describe("containsData", () => {
|
||||
it("returns false at first", async () => {
|
||||
expect(await store.containsData()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after startup and account setup", async () => {
|
||||
await store.startup();
|
||||
await store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
store.storeAccount(txn, "not a real account");
|
||||
});
|
||||
expect(await store.containsData()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrationState", () => {
|
||||
beforeEach(async () => {
|
||||
await store.startup();
|
||||
});
|
||||
|
||||
it("returns 0 at first", async () => {
|
||||
expect(await store.getMigrationState()).toEqual(MigrationState.NOT_STARTED);
|
||||
});
|
||||
|
||||
it("stores updates", async () => {
|
||||
await store.setMigrationState(MigrationState.INITIAL_DATA_MIGRATED);
|
||||
expect(await store.getMigrationState()).toEqual(MigrationState.INITIAL_DATA_MIGRATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get/delete EndToEndSessionsBatch", () => {
|
||||
beforeEach(async () => {
|
||||
await store.startup();
|
||||
});
|
||||
|
||||
it("returns null at first", async () => {
|
||||
expect(await store.getEndToEndSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
it("returns a batch of sessions", async () => {
|
||||
// First store some sessions in the db
|
||||
const N_DEVICES = 6;
|
||||
const N_SESSIONS_PER_DEVICE = 6;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
let nSessions = 0;
|
||||
await store.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) =>
|
||||
store.countEndToEndSessions(txn, (n) => (nSessions = n)),
|
||||
);
|
||||
expect(nSessions).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Then, get a batch and check it looks right.
|
||||
const batch = await store.getEndToEndSessionsBatch();
|
||||
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
for (let i = 0; i < N_DEVICES; i++) {
|
||||
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
|
||||
const r = batch![i * N_DEVICES + j];
|
||||
|
||||
expect(r.deviceKey).toEqual(`device${i}`);
|
||||
expect(r.sessionId).toEqual(`session${j}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns another batch of sessions after the first batch is deleted", async () => {
|
||||
// First store some sessions in the db
|
||||
const N_DEVICES = 8;
|
||||
const N_SESSIONS_PER_DEVICE = 8;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Get the first batch
|
||||
const batch = (await store.getEndToEndSessionsBatch())!;
|
||||
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndSessionsBatch(batch);
|
||||
|
||||
// Fetch a second batch
|
||||
const batch2 = (await store.getEndToEndSessionsBatch())!;
|
||||
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndSessionsBatch(batch2);
|
||||
|
||||
// the batch should now be null.
|
||||
expect(await store.getEndToEndSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
/** Create a bunch of fake Olm sessions and stash them in the DB. */
|
||||
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
|
||||
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_SESSIONS, (txn) => {
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
store.storeEndToEndSession(
|
||||
`device${i}`,
|
||||
`session${j}`,
|
||||
{
|
||||
deviceKey: `device${i}`,
|
||||
sessionId: `session${j}`,
|
||||
},
|
||||
txn,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("get/delete EndToEndInboundGroupSessionsBatch", () => {
|
||||
beforeEach(async () => {
|
||||
await store.startup();
|
||||
});
|
||||
|
||||
it("returns null at first", async () => {
|
||||
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
it("returns a batch of sessions", async () => {
|
||||
const N_DEVICES = 6;
|
||||
const N_SESSIONS_PER_DEVICE = 6;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Mark one of the sessions as needing backup
|
||||
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_BACKUP, async (txn) => {
|
||||
await store.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }], txn);
|
||||
});
|
||||
|
||||
expect(await store.countEndToEndInboundGroupSessions()).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
|
||||
const batch = await store.getEndToEndInboundGroupSessionsBatch();
|
||||
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
|
||||
for (let i = 0; i < N_DEVICES; i++) {
|
||||
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
|
||||
const r = batch![i * N_DEVICES + j];
|
||||
|
||||
expect(r.senderKey).toEqual(pad43(`device${i}`));
|
||||
expect(r.sessionId).toEqual(`session${j}`);
|
||||
|
||||
// only the last session needs backup
|
||||
expect(r.needsBackup).toBe(i === 5 && j === 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns another batch of sessions after the first batch is deleted", async () => {
|
||||
// First store some sessions in the db
|
||||
const N_DEVICES = 8;
|
||||
const N_SESSIONS_PER_DEVICE = 8;
|
||||
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
|
||||
|
||||
// Get the first batch
|
||||
const batch = (await store.getEndToEndInboundGroupSessionsBatch())!;
|
||||
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndInboundGroupSessionsBatch(batch);
|
||||
|
||||
// Fetch a second batch
|
||||
const batch2 = (await store.getEndToEndInboundGroupSessionsBatch())!;
|
||||
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
|
||||
|
||||
// ... and delete.
|
||||
await store.deleteEndToEndInboundGroupSessionsBatch(batch2);
|
||||
|
||||
// the batch should now be null.
|
||||
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
|
||||
});
|
||||
|
||||
/** Create a bunch of fake megolm sessions and stash them in the DB. */
|
||||
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
|
||||
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, (txn) => {
|
||||
for (let i = 0; i < nDevices; i++) {
|
||||
for (let j = 0; j < nSessionsPerDevice; j++) {
|
||||
store.storeEndToEndInboundGroupSession(
|
||||
pad43(`device${i}`),
|
||||
`session${j}`,
|
||||
{
|
||||
forwardingCurve25519KeyChain: [],
|
||||
keysClaimed: {},
|
||||
room_id: "",
|
||||
session: "",
|
||||
},
|
||||
txn,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Pad a string to 43 characters long */
|
||||
function pad43(x: string): string {
|
||||
return x + ".".repeat(43 - x.length);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import "../../../olm-loader";
|
||||
import { MatrixClient, MatrixEvent } from "../../../../src/matrix";
|
||||
import { encodeBase64 } from "../../../../src/crypto/olmlib";
|
||||
import { encodeBase64 } from "../../../../src/base64";
|
||||
import "../../../../src/crypto"; // import this to cycle-break
|
||||
import { CrossSigningInfo } from "../../../../src/crypto/CrossSigning";
|
||||
import { VerificationRequest } from "../../../../src/crypto/verification/request/VerificationRequest";
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("eventMapperFor", function () {
|
||||
getRoom(roomId: string): Room | null {
|
||||
return rooms.find((r) => r.roomId === roomId) ?? null;
|
||||
},
|
||||
setUserCreator(_) {},
|
||||
} as IStore,
|
||||
scheduler: {
|
||||
setProcessFunction: jest.fn(),
|
||||
|
||||
@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
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 {
|
||||
ClientPrefix,
|
||||
HttpApiEvent,
|
||||
HttpApiEventHandlerMap,
|
||||
IdentityPrefix,
|
||||
IHttpOpts,
|
||||
MatrixError,
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, QueryDict } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
@@ -231,13 +239,145 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
describe("authedRequest", () => {
|
||||
it("should not include token if unset", () => {
|
||||
const fetchFn = jest.fn();
|
||||
it("should not include token if unset", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
|
||||
api.authedRequest(Method.Post, "/account/password");
|
||||
await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("with refresh token", () => {
|
||||
const accessToken = "test-access-token";
|
||||
const refreshToken = "test-refresh-token";
|
||||
|
||||
describe("when an unknown token error is encountered", () => {
|
||||
const unknownTokenErrBody = {
|
||||
errcode: "M_UNKNOWN_TOKEN",
|
||||
error: "Token is not active",
|
||||
soft_logout: false,
|
||||
};
|
||||
const unknownTokenErr = new MatrixError(unknownTokenErrBody, 401);
|
||||
const unknownTokenResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
return name === "Content-Type" ? "application/json" : null;
|
||||
},
|
||||
},
|
||||
text: jest.fn().mockResolvedValue(JSON.stringify(unknownTokenErrBody)),
|
||||
};
|
||||
const okayResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
};
|
||||
|
||||
describe("without a tokenRefreshFunction", () => {
|
||||
it("should emit logout and throw", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, accessToken, refreshToken });
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a tokenRefreshFunction", () => {
|
||||
it("should emit logout and throw when token refresh fails", async () => {
|
||||
const error = new Error("uh oh");
|
||||
const tokenRefreshFunction = jest.fn().mockRejectedValue(error);
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should refresh token and retry request", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(unknownTokenResponse)
|
||||
.mockResolvedValueOnce(okayResponse);
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(result).toEqual(okayResponse);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// uses new access token
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
const fetchFn = jest.fn().mockResolvedValue(unknownTokenResponse);
|
||||
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
jest.spyOn(emitter, "emit");
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
// uses new access token on retry
|
||||
expect(fetchFn.mock.calls[1][1].headers.Authorization).toEqual("Bearer new-access-token");
|
||||
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl()", () => {
|
||||
@@ -300,22 +440,29 @@ describe("FetchHttpApi", () => {
|
||||
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 mockLogger = {
|
||||
debug: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
logger: mockLogger,
|
||||
});
|
||||
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(`
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("param");
|
||||
expect(mockLogger.debug).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogger.debug.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: --> GET https://server:8448/some/path?query=xxx",
|
||||
]
|
||||
`);
|
||||
expect(mocked(logger.debug).mock.calls[1]).toMatchInlineSnapshot(`
|
||||
expect(mockLogger.debug.mock.calls[1]).toMatchInlineSnapshot(`
|
||||
[
|
||||
"FetchHttpApi: <-- GET https://server:8448/some/path?query=xxx [1234ms 200]",
|
||||
]
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
RuleId,
|
||||
IPushRule,
|
||||
ConditionKind,
|
||||
getHttpUriForMxc,
|
||||
} from "../../src";
|
||||
import { supportsMatrixCall } from "../../src/webrtc/call";
|
||||
import { makeBeaconEvent } from "../test-utils/beacon";
|
||||
@@ -64,7 +65,7 @@ import {
|
||||
PolicyScope,
|
||||
} from "../../src/models/invites-ignorer";
|
||||
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
|
||||
import { QueryDict } from "../../src/utils";
|
||||
import { defer, QueryDict } from "../../src/utils";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
@@ -328,6 +329,7 @@ describe("MatrixClient", function () {
|
||||
"storeFilter",
|
||||
"startup",
|
||||
"deleteAllData",
|
||||
"setUserCreator",
|
||||
] as const
|
||||
).reduce((r, k) => {
|
||||
r[k] = jest.fn();
|
||||
@@ -368,6 +370,21 @@ describe("MatrixClient", function () {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
describe("mxcUrlToHttp", () => {
|
||||
it("should call getHttpUriForMxc", () => {
|
||||
const mxc = "mxc://server/example";
|
||||
expect(client.mxcUrlToHttp(mxc)).toBe(getHttpUriForMxc(client.baseUrl, mxc));
|
||||
expect(client.mxcUrlToHttp(mxc, 32)).toBe(getHttpUriForMxc(client.baseUrl, mxc, 32));
|
||||
expect(client.mxcUrlToHttp(mxc, 32, 46)).toBe(getHttpUriForMxc(client.baseUrl, mxc, 32, 46));
|
||||
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale")).toBe(
|
||||
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale"),
|
||||
);
|
||||
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true)).toBe(
|
||||
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestampToEvent", () => {
|
||||
const roomId = "!room:server.org";
|
||||
const eventId = "$eventId:example.org";
|
||||
@@ -1433,25 +1450,11 @@ describe("MatrixClient", function () {
|
||||
const mockRoom = {
|
||||
getMyMembership: () => "join",
|
||||
updatePendingEvent: (event: MatrixEvent, status: EventStatus) => event.setStatus(status),
|
||||
currentState: {
|
||||
getStateEvents: (eventType, stateKey) => {
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[RoomCreateTypeField]: RoomType.Space,
|
||||
},
|
||||
});
|
||||
} else if (eventType === EventType.RoomEncryption) {
|
||||
expect(stateKey).toEqual("");
|
||||
return new MatrixEvent({ content: {} });
|
||||
} else {
|
||||
throw new Error("Unexpected event type or state key");
|
||||
}
|
||||
},
|
||||
} as Room["currentState"],
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(true),
|
||||
} as unknown as Room;
|
||||
|
||||
let mockCrypto: Mocked<Crypto>;
|
||||
|
||||
let event: MatrixEvent;
|
||||
beforeEach(async () => {
|
||||
event = new MatrixEvent({
|
||||
@@ -1466,11 +1469,12 @@ describe("MatrixClient", function () {
|
||||
expect(getRoomId).toEqual(roomId);
|
||||
return mockRoom;
|
||||
};
|
||||
client.crypto = client["cryptoBackend"] = {
|
||||
// mock crypto
|
||||
encryptEvent: () => new Promise(() => {}),
|
||||
mockCrypto = {
|
||||
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true),
|
||||
encryptEvent: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
} as unknown as Crypto;
|
||||
} as unknown as Mocked<Crypto>;
|
||||
client.crypto = client["cryptoBackend"] = mockCrypto;
|
||||
});
|
||||
|
||||
function assertCancelled() {
|
||||
@@ -1487,12 +1491,21 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
it("should cancel an event which is encrypting", async () => {
|
||||
const encryptEventDefer = defer();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
|
||||
|
||||
const statusPromise = testUtils.emitPromise(event, "Event.status");
|
||||
// @ts-ignore protected method access
|
||||
client.encryptAndSendEvent(mockRoom, event);
|
||||
await testUtils.emitPromise(event, "Event.status");
|
||||
const encryptAndSendPromise = client.encryptAndSendEvent(mockRoom, event);
|
||||
await statusPromise;
|
||||
expect(event.status).toBe(EventStatus.ENCRYPTING);
|
||||
client.cancelPendingEvent(event);
|
||||
assertCancelled();
|
||||
|
||||
// now let the encryption complete, and check that the message is not sent.
|
||||
encryptEventDefer.resolve();
|
||||
await encryptAndSendPromise;
|
||||
assertCancelled();
|
||||
});
|
||||
|
||||
it("should cancel an event which is not sent", () => {
|
||||
@@ -2210,8 +2223,7 @@ describe("MatrixClient", function () {
|
||||
"org.matrix.msc3391": true,
|
||||
},
|
||||
};
|
||||
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
|
||||
const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391";
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
@@ -2249,8 +2261,7 @@ describe("MatrixClient", function () {
|
||||
"org.matrix.msc3391": false,
|
||||
},
|
||||
};
|
||||
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
|
||||
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
|
||||
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
|
||||
|
||||
// populate version support
|
||||
|
||||
@@ -23,21 +23,23 @@ const membershipTemplate: CallMembershipData = {
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 5000,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
|
||||
function makeMockEvent(originTs = 0): MatrixEvent {
|
||||
return {
|
||||
getTs: jest.fn().mockReturnValue(originTs),
|
||||
sender: {
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
getSender: jest.fn().mockReturnValue("@alice:example.org"),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
describe("CallMembership", () => {
|
||||
it("rejects membership with no expiry", () => {
|
||||
it("rejects membership with no expiry and no expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: undefined }));
|
||||
new CallMembership(
|
||||
makeMockEvent(),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
@@ -58,6 +60,16 @@ describe("CallMembership", () => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires_ts", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
it("rejects with malformatted expires", () => {
|
||||
expect(() => {
|
||||
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("uses event timestamp if no created_ts", () => {
|
||||
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
|
||||
@@ -72,11 +84,19 @@ describe("CallMembership", () => {
|
||||
expect(membership.createdTs()).toEqual(67890);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time", () => {
|
||||
it("computes absolute expiry time based on expires", () => {
|
||||
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("computes absolute expiry time based on expires_ts", () => {
|
||||
const membership = new CallMembership(
|
||||
makeMockEvent(1000),
|
||||
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }),
|
||||
);
|
||||
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
|
||||
});
|
||||
|
||||
it("considers memberships unexpired if local age low enough", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
|
||||
@@ -86,7 +106,7 @@ describe("CallMembership", () => {
|
||||
|
||||
it("considers memberships expired when local age large", () => {
|
||||
const fakeEvent = makeMockEvent(1000);
|
||||
fakeEvent.getLocalAge = jest.fn().mockReturnValue(6000);
|
||||
fakeEvent.localTimestamp = Date.now() - 6000;
|
||||
const membership = new CallMembership(fakeEvent, membershipTemplate);
|
||||
expect(membership.isExpired()).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventTimeline, EventType, MatrixClient, Room } from "../../../src";
|
||||
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||
import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
import { makeMockRoom, mockRTCEvent } from "./mocks";
|
||||
import { makeMockRoom, makeMockRoomState, mockRTCEvent } from "./mocks";
|
||||
|
||||
const membershipTemplate: CallMembershipData = {
|
||||
call_id: "",
|
||||
@@ -26,6 +27,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
|
||||
const mockFocus = { type: "mock" };
|
||||
@@ -56,14 +58,16 @@ describe("MatrixRTCSession", () => {
|
||||
expect(sess?.memberships[0].scope).toEqual("m.room");
|
||||
expect(sess?.memberships[0].application).toEqual("m.call");
|
||||
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
||||
expect(sess?.memberships[0].membershipID).toEqual("bloop");
|
||||
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
||||
expect(sess?.callId).toEqual("");
|
||||
});
|
||||
|
||||
it("ignores expired memberships events", () => {
|
||||
const expiredMembership = Object.assign({}, membershipTemplate);
|
||||
expiredMembership.expires = 1000;
|
||||
expiredMembership.device_id = "EXPIRED";
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], () => 10000);
|
||||
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], 10000);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
expect(sess?.memberships.length).toEqual(1);
|
||||
@@ -182,8 +186,15 @@ describe("MatrixRTCSession", () => {
|
||||
|
||||
describe("joining", () => {
|
||||
let mockRoom: Room;
|
||||
let sendStateEventMock: jest.Mock;
|
||||
let sendEventMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendStateEventMock = jest.fn();
|
||||
sendEventMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
client.sendEvent = sendEventMock;
|
||||
|
||||
mockRoom = makeMockRoom([]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
});
|
||||
@@ -203,10 +214,8 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("sends a membership event when joining a call", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
jest.useFakeTimers();
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
@@ -218,18 +227,18 @@ describe("MatrixRTCSession", () => {
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
expires_ts: Date.now() + 3600000,
|
||||
foci_active: [{ type: "mock" }],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", () => {
|
||||
const sendStateEventMock = jest.fn();
|
||||
client.sendStateEvent = sendStateEventMock;
|
||||
|
||||
sess!.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -259,7 +268,7 @@ describe("MatrixRTCSession", () => {
|
||||
const timeElapsed = 60 * 60 * 1000 - 1000;
|
||||
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, () => timeElapsed));
|
||||
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed));
|
||||
|
||||
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
|
||||
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
|
||||
@@ -284,8 +293,10 @@ describe("MatrixRTCSession", () => {
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000 * 2,
|
||||
expires_ts: 1000 + 3600000 * 2,
|
||||
foci_active: [{ type: "mock" }],
|
||||
created_ts: 1000,
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -295,15 +306,244 @@ describe("MatrixRTCSession", () => {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a key when joining", () => {
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
|
||||
expect(keys).toHaveLength(1);
|
||||
|
||||
const allKeys = sess!.getEncryptionKeys();
|
||||
expect(allKeys).toBeTruthy();
|
||||
expect(Array.from(allKeys)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends keys when joining", async () => {
|
||||
const eventSentPromise = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalledWith(expect.stringMatching(".*"), "io.element.call.encryption_keys", {
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retries key sends", async () => {
|
||||
jest.useFakeTimers();
|
||||
let firstEventSent = false;
|
||||
|
||||
try {
|
||||
const eventSentPromise = new Promise<void>((resolve) => {
|
||||
sendEventMock.mockImplementation(() => {
|
||||
if (!firstEventSent) {
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
firstEventSent = true;
|
||||
const e = new Error() as MatrixError;
|
||||
e.data = {};
|
||||
throw e;
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
await eventSentPromise;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels key send event that fail", async () => {
|
||||
const eventSentinel = {} as unknown as MatrixEvent;
|
||||
|
||||
client.cancelPendingEvent = jest.fn();
|
||||
sendEventMock.mockImplementation(() => {
|
||||
const e = new Error() as MatrixError;
|
||||
e.data = {};
|
||||
e.event = eventSentinel;
|
||||
throw e;
|
||||
});
|
||||
|
||||
sess!.joinRoomSession([mockFocus], true);
|
||||
|
||||
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
|
||||
});
|
||||
|
||||
it("Re-sends key if a new member joins", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
const keysSentPromise2 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await keysSentPromise2;
|
||||
|
||||
expect(sendEventMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Rotates key if a member leaves", async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
const mockRoom = makeMockRoom([membershipTemplate, member2]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
const onMyEncryptionKeyChanged = jest.fn();
|
||||
sess.on(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
(_key: Uint8Array, _idx: number, participantId: string) => {
|
||||
if (participantId === `${client.getUserId()}:${client.getDeviceId()}`) {
|
||||
onMyEncryptionKeyChanged();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
const firstKeysPayload = await keysSentPromise1;
|
||||
expect(firstKeysPayload.keys).toHaveLength(1);
|
||||
|
||||
sendEventMock.mockClear();
|
||||
|
||||
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
|
||||
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
|
||||
});
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId, undefined));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
const secondKeysPayload = await keysSentPromise2;
|
||||
|
||||
expect(secondKeysPayload.keys).toHaveLength(2);
|
||||
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't re-send key immediately", async () => {
|
||||
const realSetImmediate = setImmediate;
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
const keysSentPromise1 = new Promise((resolve) => {
|
||||
sendEventMock.mockImplementation(resolve);
|
||||
});
|
||||
|
||||
sess.joinRoomSession([mockFocus], true);
|
||||
await keysSentPromise1;
|
||||
|
||||
sendEventMock.mockClear();
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
const member2 = Object.assign({}, membershipTemplate, {
|
||||
device_id: "BBBBBBB",
|
||||
});
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
await new Promise((resolve) => {
|
||||
realSetImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(sendEventMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("Does not emit if no membership changes", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
expect(onMembershipsChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Emits on membership changes", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
mockRoom.getLiveTimeline().getState = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeMockRoomState([], mockRoom.roomId, undefined));
|
||||
sess.onMembershipUpdate();
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits an event at the time a membership event expires", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
let eventAge = 0;
|
||||
|
||||
const membership = Object.assign({}, membershipTemplate);
|
||||
const mockRoom = makeMockRoom([membership], () => eventAge);
|
||||
const mockRoom = makeMockRoom([membership], 0);
|
||||
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
const membershipObject = sess.memberships[0];
|
||||
@@ -311,7 +551,6 @@ describe("MatrixRTCSession", () => {
|
||||
const onMembershipsChanged = jest.fn();
|
||||
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
||||
|
||||
eventAge = 61 * 1000 * 1000;
|
||||
jest.advanceTimersByTime(61 * 1000 * 1000);
|
||||
|
||||
expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []);
|
||||
@@ -322,51 +561,55 @@ describe("MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
it("prunes expired memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
let eventAge = 0;
|
||||
|
||||
const mockRoom = makeMockRoom(
|
||||
[
|
||||
const mockMemberships = [
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 1000,
|
||||
}),
|
||||
],
|
||||
() => eventAge,
|
||||
);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
];
|
||||
const mockRoomNoExpired = makeMockRoom(mockMemberships, 0);
|
||||
|
||||
// sanity check
|
||||
expect(sess.memberships).toHaveLength(1);
|
||||
expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE");
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoomNoExpired);
|
||||
|
||||
eventAge = 10000;
|
||||
// sanity check
|
||||
expect(sess.memberships).toHaveLength(1);
|
||||
expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE");
|
||||
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
jest.advanceTimersByTime(10000);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoom!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
foci_active: [mockFocus],
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
sess.joinRoomSession([mockFocus]);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
mockRoomNoExpired!.roomId,
|
||||
EventType.GroupCallMemberPrefix,
|
||||
{
|
||||
memberships: [
|
||||
{
|
||||
application: "m.call",
|
||||
scope: "m.room",
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
expires_ts: Date.now() + 3600000,
|
||||
foci_active: [mockFocus],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fills in created_ts for other memberships on update", () => {
|
||||
client.sendStateEvent = jest.fn();
|
||||
|
||||
jest.useFakeTimers();
|
||||
const mockRoom = makeMockRoom([
|
||||
Object.assign({}, membershipTemplate, {
|
||||
device_id: "OTHERDEVICE",
|
||||
@@ -388,6 +631,7 @@ describe("MatrixRTCSession", () => {
|
||||
device_id: "OTHERDEVICE",
|
||||
expires: 3600000,
|
||||
created_ts: 1000,
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
{
|
||||
application: "m.call",
|
||||
@@ -395,11 +639,86 @@ describe("MatrixRTCSession", () => {
|
||||
call_id: "",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 3600000,
|
||||
expires_ts: Date.now() + 3600000,
|
||||
foci_active: [mockFocus],
|
||||
membershipID: expect.stringMatching(".*"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"@alice:example.org",
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("collects keys from encryption events", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 0,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
expect(bobKeys).toHaveLength(1);
|
||||
expect(bobKeys[0]).toEqual(Buffer.from("this is the key", "utf-8"));
|
||||
});
|
||||
|
||||
it("collects keys at non-zero indices", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: "bobsphone",
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@bob:example.org"),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!;
|
||||
expect(bobKeys).toHaveLength(5);
|
||||
expect(bobKeys[0]).toBeFalsy();
|
||||
expect(bobKeys[1]).toBeFalsy();
|
||||
expect(bobKeys[2]).toBeFalsy();
|
||||
expect(bobKeys[3]).toBeFalsy();
|
||||
expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8"));
|
||||
});
|
||||
|
||||
it("ignores keys event for the local participant", () => {
|
||||
const mockRoom = makeMockRoom([membershipTemplate]);
|
||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||
sess.onCallEncryption({
|
||||
getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
device_id: client.getDeviceId(),
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
index: 4,
|
||||
key: "dGhpcyBpcyB0aGUga2V5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue(client.getUserId()),
|
||||
} as unknown as MatrixEvent);
|
||||
|
||||
const myKeys = sess.getKeysForParticipant(client.getUserId()!, client.getDeviceId()!)!;
|
||||
expect(myKeys).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
IRoomTimelineData,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
RoomEvent,
|
||||
} from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
@@ -26,6 +34,7 @@ const membershipTemplate: CallMembershipData = {
|
||||
application: "m.call",
|
||||
device_id: "AAAAAAA",
|
||||
expires: 60 * 60 * 1000,
|
||||
membershipID: "bloop",
|
||||
};
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
@@ -77,4 +86,26 @@ describe("MatrixRTCSessionManager", () => {
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", () => {
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
|
||||
const timelineEvent = {
|
||||
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getRoomId: jest.fn().mockReturnValue("!room:id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,37 +18,29 @@ import { EventType, MatrixEvent, Room } from "../../../src";
|
||||
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { randomString } from "../../../src/randomstring";
|
||||
|
||||
export function makeMockRoom(
|
||||
memberships: CallMembershipData[],
|
||||
getLocalAge: (() => number) | undefined = undefined,
|
||||
): Room {
|
||||
export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room {
|
||||
const roomId = randomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(memberships, roomId, localAge);
|
||||
return {
|
||||
roomId: roomId,
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(makeMockRoomState(memberships, roomId, getLocalAge)),
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
} as unknown as Room;
|
||||
}
|
||||
|
||||
function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) {
|
||||
export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) {
|
||||
const event = mockRTCEvent(memberships, roomId, localAge);
|
||||
return {
|
||||
getStateEvents: (_: string, stateKey: string) => {
|
||||
const event = mockRTCEvent(memberships, roomId, getLocalAge);
|
||||
|
||||
if (stateKey !== undefined) return event;
|
||||
return [event];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(
|
||||
memberships: CallMembershipData[],
|
||||
roomId: string,
|
||||
getLocalAge: (() => number) | undefined,
|
||||
): MatrixEvent {
|
||||
const getLocalAgeFn = getLocalAge ?? (() => 10);
|
||||
|
||||
export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue({
|
||||
@@ -56,8 +48,7 @@ export function mockRTCEvent(
|
||||
}),
|
||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
||||
getTs: jest.fn().mockReturnValue(1000),
|
||||
getLocalAge: getLocalAgeFn,
|
||||
localTimestamp: Date.now(),
|
||||
localTimestamp: Date.now() - (localAge ?? 10),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("MSC3089Branch", () => {
|
||||
expect(eventId).toEqual(fileEventId);
|
||||
return fileEvent;
|
||||
},
|
||||
} as EventTimelineSet);
|
||||
}) as EventTimelineSet;
|
||||
client.mxcUrlToHttp = (mxc: string) => {
|
||||
expect(mxc).toEqual("mxc://" + mxcLatter);
|
||||
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
|
||||
@@ -239,7 +239,7 @@ describe("MSC3089Branch", () => {
|
||||
expect(eventId).toEqual(fileEventId);
|
||||
return fileEvent;
|
||||
},
|
||||
} as EventTimelineSet);
|
||||
}) as EventTimelineSet;
|
||||
client.mxcUrlToHttp = (mxc: string) => {
|
||||
expect(mxc).toEqual("mxc://" + mxcLatter);
|
||||
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
|
||||
@@ -332,7 +332,7 @@ describe("MSC3089Branch", () => {
|
||||
getId: () => "$unknown",
|
||||
},
|
||||
];
|
||||
staticRoom.getLiveTimeline = () => ({ getEvents: () => events } as EventTimeline);
|
||||
staticRoom.getLiveTimeline = () => ({ getEvents: () => events }) as EventTimeline;
|
||||
|
||||
directory.getFile = (evId: string) => {
|
||||
expect(evId).toEqual(fileEventId);
|
||||
|
||||
@@ -399,7 +399,7 @@ describe("MSC3089TreeSpace", () => {
|
||||
];
|
||||
},
|
||||
};
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
|
||||
|
||||
const getFn = jest.fn().mockImplementation((roomId: string) => {
|
||||
if (roomId === thirdChildRoom) {
|
||||
@@ -422,7 +422,7 @@ describe("MSC3089TreeSpace", () => {
|
||||
});
|
||||
|
||||
it("should find specific directories", () => {
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
|
||||
|
||||
// Only mocking used API
|
||||
const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace;
|
||||
|
||||
+279
-21
@@ -14,10 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MockedObject } from "jest-mock";
|
||||
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { Crypto, IEventDecryptionResult } from "../../../src/crypto";
|
||||
import { IAnnotatedPushRule, PushRuleActionName, TweakName } from "../../../src";
|
||||
import {
|
||||
IAnnotatedPushRule,
|
||||
MatrixClient,
|
||||
PushRuleActionName,
|
||||
Room,
|
||||
THREAD_RELATION_TYPE,
|
||||
TweakName,
|
||||
} from "../../../src";
|
||||
|
||||
describe("MatrixEvent", () => {
|
||||
it("should create copies of itself", () => {
|
||||
@@ -61,31 +70,264 @@ describe("MatrixEvent", () => {
|
||||
expect(a.toSnapshot().isEquivalentTo(b)).toBe(false);
|
||||
});
|
||||
|
||||
it("should prune clearEvent when being redacted", () => {
|
||||
const ev = new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "Test",
|
||||
},
|
||||
event_id: "$event1:server",
|
||||
describe("redaction", () => {
|
||||
it("should prune clearEvent when being redacted", () => {
|
||||
const ev = createEvent("$event1:server", "Test");
|
||||
|
||||
expect(ev.getContent().body).toBe("Test");
|
||||
expect(ev.getWireContent().body).toBe("Test");
|
||||
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
|
||||
expect(ev.getContent().body).toBe("Test");
|
||||
expect(ev.getWireContent().body).toBeUndefined();
|
||||
expect(ev.getWireContent().ciphertext).toBe("xyz");
|
||||
|
||||
const mockClient = {} as unknown as MockedObject<MatrixClient>;
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
|
||||
ev.makeRedacted(redaction, room);
|
||||
expect(ev.getContent().body).toBeUndefined();
|
||||
expect(ev.getWireContent().body).toBeUndefined();
|
||||
expect(ev.getWireContent().ciphertext).toBeUndefined();
|
||||
});
|
||||
|
||||
expect(ev.getContent().body).toBe("Test");
|
||||
expect(ev.getWireContent().body).toBe("Test");
|
||||
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
|
||||
expect(ev.getContent().body).toBe("Test");
|
||||
expect(ev.getWireContent().body).toBeUndefined();
|
||||
expect(ev.getWireContent().ciphertext).toBe("xyz");
|
||||
it("should remain in the main timeline when redacted", async () => {
|
||||
// Given an event in the main timeline
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const ev = createEvent("$event1:server");
|
||||
|
||||
const redaction = new MatrixEvent({
|
||||
type: "m.room.redaction",
|
||||
redacts: ev.getId(),
|
||||
await room.addLiveEvents([ev]);
|
||||
await room.createThreadsTimelineSets();
|
||||
expect(ev.threadRootId).toBeUndefined();
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]);
|
||||
|
||||
// When I redact it
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
ev.makeRedacted(redaction, room);
|
||||
|
||||
// Then it remains in the main timeline
|
||||
expect(ev.threadRootId).toBeUndefined();
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]);
|
||||
});
|
||||
|
||||
ev.makeRedacted(redaction);
|
||||
expect(ev.getContent().body).toBeUndefined();
|
||||
expect(ev.getWireContent().body).toBeUndefined();
|
||||
expect(ev.getWireContent().ciphertext).toBeUndefined();
|
||||
it("should keep thread roots in both timelines when redacted", async () => {
|
||||
// Given a thread exists
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const threadRoot = createEvent("$threadroot:server");
|
||||
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
|
||||
|
||||
await room.addLiveEvents([threadRoot, ev]);
|
||||
await room.createThreadsTimelineSets();
|
||||
expect(threadRoot.threadRootId).toEqual(threadRoot.getId());
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]);
|
||||
|
||||
// When I redact the thread root
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
threadRoot.makeRedacted(redaction, room);
|
||||
|
||||
// Then it remains in the main timeline and the thread
|
||||
expect(threadRoot.threadRootId).toEqual(threadRoot.getId());
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]);
|
||||
});
|
||||
|
||||
it("should move into the main timeline when redacted", async () => {
|
||||
// Given an event in a thread
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const threadRoot = createEvent("$threadroot:server");
|
||||
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
|
||||
|
||||
await room.addLiveEvents([threadRoot, ev]);
|
||||
await room.createThreadsTimelineSets();
|
||||
expect(ev.threadRootId).toEqual(threadRoot.getId());
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]);
|
||||
|
||||
// When I redact it
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
ev.makeRedacted(redaction, room);
|
||||
|
||||
// Then it disappears from the thread and appears in the main timeline
|
||||
expect(ev.threadRootId).toBeUndefined();
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).not.toContain(ev.getId());
|
||||
});
|
||||
|
||||
it("should move reactions to a redacted event into the main timeline", async () => {
|
||||
// Given an event in a thread with a reaction
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const threadRoot = createEvent("$threadroot:server");
|
||||
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
|
||||
const reaction = createReactionEvent("$reaction:server", ev.getId()!);
|
||||
|
||||
await room.addLiveEvents([threadRoot, ev, reaction]);
|
||||
await room.createThreadsTimelineSets();
|
||||
expect(reaction.threadRootId).toEqual(threadRoot.getId());
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId(), reaction.getId()]);
|
||||
|
||||
// When I redact the event
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
ev.makeRedacted(redaction, room);
|
||||
|
||||
// Then the reaction moves into the main timeline
|
||||
expect(reaction.threadRootId).toBeUndefined();
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId(), reaction.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).not.toContain(reaction.getId());
|
||||
});
|
||||
|
||||
it("should move edits of a redacted event into the main timeline", async () => {
|
||||
// Given an event in a thread with a reaction
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const threadRoot = createEvent("$threadroot:server");
|
||||
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
|
||||
const edit = createEditEvent("$edit:server", ev.getId()!);
|
||||
|
||||
await room.addLiveEvents([threadRoot, ev, edit]);
|
||||
await room.createThreadsTimelineSets();
|
||||
expect(edit.threadRootId).toEqual(threadRoot.getId());
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId(), edit.getId()]);
|
||||
|
||||
// When I redact the event
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
ev.makeRedacted(redaction, room);
|
||||
|
||||
// Then the edit moves into the main timeline
|
||||
expect(edit.threadRootId).toBeUndefined();
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId(), edit.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).not.toContain(edit.getId());
|
||||
});
|
||||
|
||||
it("should move reactions to replies to replies a redacted event into the main timeline", async () => {
|
||||
// Given an event in a thread with a reaction
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!roomid:e.xyz", mockClient, "myname");
|
||||
const threadRoot = createEvent("$threadroot:server");
|
||||
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
|
||||
const reply1 = createReplyEvent("$reply1:server", ev.getId()!);
|
||||
const reply2 = createReplyEvent("$reply2:server", reply1.getId()!);
|
||||
const reaction = createReactionEvent("$reaction:server", reply2.getId()!);
|
||||
|
||||
await room.addLiveEvents([threadRoot, ev, reply1, reply2, reaction]);
|
||||
await room.createThreadsTimelineSets();
|
||||
expect(reaction.threadRootId).toEqual(threadRoot.getId());
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
|
||||
expect(threadLiveEventIds(room, 0)).toEqual([
|
||||
threadRoot.getId(),
|
||||
ev.getId(),
|
||||
reply1.getId(),
|
||||
reply2.getId(),
|
||||
reaction.getId(),
|
||||
]);
|
||||
|
||||
// When I redact the event
|
||||
const redaction = createRedaction(ev.getId()!);
|
||||
ev.makeRedacted(redaction, room);
|
||||
|
||||
// Then the replies move to the main thread and the reaction disappears
|
||||
expect(reaction.threadRootId).toBeUndefined();
|
||||
expect(mainTimelineLiveEventIds(room)).toEqual([
|
||||
threadRoot.getId(),
|
||||
ev.getId(),
|
||||
reply1.getId(),
|
||||
reply2.getId(),
|
||||
reaction.getId(),
|
||||
]);
|
||||
expect(threadLiveEventIds(room, 0)).not.toContain(reply1.getId());
|
||||
expect(threadLiveEventIds(room, 0)).not.toContain(reply2.getId());
|
||||
expect(threadLiveEventIds(room, 0)).not.toContain(reaction.getId());
|
||||
});
|
||||
|
||||
function createMockClient(): MatrixClient {
|
||||
return {
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
decryptEventIfNeeded: jest.fn().mockReturnThis(),
|
||||
getUserId: jest.fn().mockReturnValue("@user:server"),
|
||||
} as unknown as MockedObject<MatrixClient>;
|
||||
}
|
||||
|
||||
function createEvent(eventId: string, body?: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: body ?? eventId,
|
||||
},
|
||||
event_id: eventId,
|
||||
});
|
||||
}
|
||||
|
||||
function createThreadedEvent(eventId: string, threadRootId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": eventId,
|
||||
"m.relates_to": {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
event_id: threadRootId,
|
||||
},
|
||||
},
|
||||
event_id: eventId,
|
||||
});
|
||||
}
|
||||
|
||||
function createEditEvent(eventId: string, repliedToId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "Edited",
|
||||
"m.new_content": {
|
||||
body: "Edited",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: repliedToId,
|
||||
rel_type: "m.replace",
|
||||
},
|
||||
},
|
||||
event_id: eventId,
|
||||
});
|
||||
}
|
||||
|
||||
function createReplyEvent(eventId: string, repliedToId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: repliedToId,
|
||||
key: "x",
|
||||
rel_type: "m.in_reply_to",
|
||||
},
|
||||
},
|
||||
event_id: eventId,
|
||||
});
|
||||
}
|
||||
|
||||
function createReactionEvent(eventId: string, reactedToId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.reaction",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: reactedToId,
|
||||
key: "x",
|
||||
rel_type: "m.annotation",
|
||||
},
|
||||
},
|
||||
event_id: eventId,
|
||||
});
|
||||
}
|
||||
|
||||
function createRedaction(redactedEventid: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.redaction",
|
||||
redacts: redactedEventid,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("applyVisibilityEvent", () => {
|
||||
@@ -330,3 +572,19 @@ describe("MatrixEvent", () => {
|
||||
expect(stateEvent.threadRootId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function mainTimelineLiveEventIds(room: Room): Array<string> {
|
||||
return room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.map((e) => e.getId()!);
|
||||
}
|
||||
|
||||
function threadLiveEventIds(room: Room, threadIndex: number): Array<string> {
|
||||
return room
|
||||
.getThreads()
|
||||
[threadIndex].getUnfilteredTimelineSet()
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.map((e) => e.getId()!);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/*
|
||||
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 { FeatureSupport, MatrixClient, MatrixEvent, ReceiptContent, THREAD_RELATION_TYPE, Thread } from "../../../src";
|
||||
import { Room } from "../../../src/models/room";
|
||||
|
||||
/**
|
||||
* Note, these tests check the functionality of the RoomReceipts class, but most
|
||||
* of them access that functionality via the surrounding Room class, because a
|
||||
* room is required for RoomReceipts to function, and this matches the pattern
|
||||
* of how this code is used in the wild.
|
||||
*/
|
||||
describe("RoomReceipts", () => {
|
||||
beforeAll(() => {
|
||||
jest.replaceProperty(Thread, "hasServerSideSupport", FeatureSupport.Stable);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports events unread if there are no receipts", () => {
|
||||
// Given there are no receipts in the room
|
||||
const room = createRoom();
|
||||
const [event] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// When I ask about any event, then it is unread
|
||||
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports events we sent as read even if there are no (real) receipts", () => {
|
||||
// Given there are no receipts in the room
|
||||
const room = createRoom();
|
||||
const [event] = createEventSentBy(readerId);
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// When I ask about an event I sent, it is read (because a synthetic
|
||||
// receipt was created and stored in RoomReceipts)
|
||||
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive an unthreaded receipt for this event", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createReceipt(readerId, event));
|
||||
|
||||
// Then that event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive an unthreaded receipt for a later event", () => {
|
||||
// Given we have 2 events
|
||||
const room = createRoom();
|
||||
const [event1, event1Id] = createEvent();
|
||||
const [event2] = createEvent();
|
||||
room.addLiveEvents([event1, event2]);
|
||||
|
||||
// When we receive a receipt for the later event
|
||||
room.addReceipt(createReceipt(readerId, event2));
|
||||
|
||||
// Then the earlier one is read
|
||||
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read for a non-live event if we receive an unthreaded receipt for a live one", () => {
|
||||
// Given we have 2 events: one live and one old
|
||||
const room = createRoom();
|
||||
const [oldEvent, oldEventId] = createEvent();
|
||||
const [liveEvent] = createEvent();
|
||||
room.addLiveEvents([liveEvent]);
|
||||
createOldTimeline(room, [oldEvent]);
|
||||
|
||||
// When we receive a receipt for the live event
|
||||
room.addReceipt(createReceipt(readerId, liveEvent));
|
||||
|
||||
// Then the earlier one is read
|
||||
expect(room.hasUserReadEvent(readerId, oldEventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("compares by timestamp if two events are in separate old timelines", () => {
|
||||
// Given we have 2 events, both in old timelines, with event2 after
|
||||
// event1 in terms of timestamps
|
||||
const room = createRoom();
|
||||
const [event1, event1Id] = createEvent();
|
||||
const [event2, event2Id] = createEvent();
|
||||
event1.event.origin_server_ts = 1;
|
||||
event2.event.origin_server_ts = 2;
|
||||
createOldTimeline(room, [event1]);
|
||||
createOldTimeline(room, [event2]);
|
||||
|
||||
// When we receive a receipt for the older event
|
||||
room.addReceipt(createReceipt(readerId, event1));
|
||||
|
||||
// Then the earlier one is read and the later one is not
|
||||
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an unthreaded receipt for an earlier event", () => {
|
||||
// Given we have 2 events
|
||||
const room = createRoom();
|
||||
const [event1] = createEvent();
|
||||
const [event2, event2Id] = createEvent();
|
||||
room.addLiveEvents([event1, event2]);
|
||||
|
||||
// When we receive a receipt for the earlier event
|
||||
room.addReceipt(createReceipt(readerId, event1));
|
||||
|
||||
// Then the later one is unread
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an unthreaded receipt for a different user", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for another user
|
||||
room.addReceipt(createReceipt(otherUserId, event));
|
||||
|
||||
// Then the event is still unread since the receipt was not for us
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// But it's read for the other person
|
||||
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports events we sent as read even if an earlier receipt arrives", () => {
|
||||
// Given we sent an event after some other event
|
||||
const room = createRoom();
|
||||
const [previousEvent] = createEvent();
|
||||
const [myEvent] = createEventSentBy(readerId);
|
||||
room.addLiveEvents([previousEvent, myEvent]);
|
||||
|
||||
// And I just received a receipt for the previous event
|
||||
room.addReceipt(createReceipt(readerId, previousEvent));
|
||||
|
||||
// When I ask about the event I sent, it is read (because of synthetic receipts)
|
||||
expect(room.hasUserReadEvent(readerId, myEvent.getId()!)).toBe(true);
|
||||
});
|
||||
|
||||
it("considers events after ones we sent to be unread", () => {
|
||||
// Given we sent an event, then another event came in
|
||||
const room = createRoom();
|
||||
const [myEvent] = createEventSentBy(readerId);
|
||||
const [laterEvent] = createEvent();
|
||||
room.addLiveEvents([myEvent, laterEvent]);
|
||||
|
||||
// When I ask about the later event, it is unread (because it's after the synthetic receipt)
|
||||
expect(room.hasUserReadEvent(readerId, laterEvent.getId()!)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reports readness even when receipts arrive out of order", () => {
|
||||
// Given we have 3 events
|
||||
const room = createRoom();
|
||||
const [event1] = createEvent();
|
||||
const [event2, event2Id] = createEvent();
|
||||
const [event3, event3Id] = createEvent();
|
||||
room.addLiveEvents([event1, event2, event3]);
|
||||
|
||||
// When we receive receipts for the older events out of order
|
||||
room.addReceipt(createReceipt(readerId, event2));
|
||||
room.addReceipt(createReceipt(readerId, event1));
|
||||
|
||||
// Then we correctly ignore the older receipt
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports read if we receive a threaded receipt for this event (main)", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
room.addLiveEvents([event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createThreadedReceipt(readerId, event, "main"));
|
||||
|
||||
// Then that event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive a threaded receipt for this event (non-main)", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event, eventId] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for this event on this thread
|
||||
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
|
||||
|
||||
// Then that event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports read if we receive an threaded receipt for a later event", () => {
|
||||
// Given we have 2 events in a thread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event1, event1Id] = createThreadedEvent(root);
|
||||
const [event2] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event1, event2]);
|
||||
|
||||
// When we receive a receipt for the later event
|
||||
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
|
||||
|
||||
// Then the earlier one is read
|
||||
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an threaded receipt for an earlier event", () => {
|
||||
// Given we have 2 events in a thread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event1] = createThreadedEvent(root);
|
||||
const [event2, event2Id] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event1, event2]);
|
||||
|
||||
// When we receive a receipt for the earlier event
|
||||
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
|
||||
|
||||
// Then the later one is unread
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unread if we receive an threaded receipt for a different user", () => {
|
||||
// Given my event exists and is unread
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event, eventId] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event]);
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// When we receive a receipt for another user
|
||||
room.addReceipt(createThreadedReceipt(otherUserId, event, rootId));
|
||||
|
||||
// Then the event is still unread since the receipt was not for us
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// But it's read for the other person
|
||||
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unread if we receive a receipt for a later event in a different thread", () => {
|
||||
// Given 2 events exist in different threads
|
||||
const room = createRoom();
|
||||
const [root1] = createEvent();
|
||||
const [root2] = createEvent();
|
||||
const [thread1, thread1Id] = createThreadedEvent(root1);
|
||||
const [thread2] = createThreadedEvent(root2);
|
||||
setupThread(room, root1);
|
||||
setupThread(room, root2);
|
||||
room.addLiveEvents([root1, root2, thread1, thread2]);
|
||||
|
||||
// When we receive a receipt for the later event
|
||||
room.addReceipt(createThreadedReceipt(readerId, thread2, root2.getId()!));
|
||||
|
||||
// Then the old one is still unread since the receipt was not for this thread
|
||||
expect(room.hasUserReadEvent(readerId, thread1Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reports readness even when threaded receipts arrive out of order", () => {
|
||||
// Given we have 3 events
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event1] = createThreadedEvent(root);
|
||||
const [event2, event2Id] = createThreadedEvent(root);
|
||||
const [event3, event3Id] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
room.addLiveEvents([root, event1, event2, event3]);
|
||||
|
||||
// When we receive receipts for the older events out of order
|
||||
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
|
||||
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
|
||||
|
||||
// Then we correctly ignore the older receipt
|
||||
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
|
||||
});
|
||||
|
||||
it("correctly reports readness when mixing threaded and unthreaded receipts", () => {
|
||||
// Given we have a setup from this presentation:
|
||||
// https://docs.google.com/presentation/d/1H1gxRmRFAm8d71hCILWmpOYezsvdlb7cB6ANl-20Gns/edit?usp=sharing
|
||||
//
|
||||
// Main1----\
|
||||
// | ---Thread1a <- threaded receipt
|
||||
// | |
|
||||
// | Thread1b
|
||||
// threaded receipt -> Main2--\
|
||||
// | ----------------Thread2a <- unthreaded receipt
|
||||
// Main3 |
|
||||
// Thread2b <- threaded receipt
|
||||
//
|
||||
const room = createRoom();
|
||||
const [main1, main1Id] = createEvent();
|
||||
const [main2, main2Id] = createEvent();
|
||||
const [main3, main3Id] = createEvent();
|
||||
const [thread1a, thread1aId] = createThreadedEvent(main1);
|
||||
const [thread1b, thread1bId] = createThreadedEvent(main1);
|
||||
const [thread2a, thread2aId] = createThreadedEvent(main2);
|
||||
const [thread2b, thread2bId] = createThreadedEvent(main2);
|
||||
setupThread(room, main1);
|
||||
setupThread(room, main2);
|
||||
room.addLiveEvents([main1, thread1a, thread1b, main2, thread2a, main3, thread2b]);
|
||||
|
||||
// And the timestamps on the events are consistent with the order above
|
||||
main1.event.origin_server_ts = 1;
|
||||
thread1a.event.origin_server_ts = 2;
|
||||
thread1b.event.origin_server_ts = 3;
|
||||
main2.event.origin_server_ts = 4;
|
||||
thread2a.event.origin_server_ts = 5;
|
||||
main3.event.origin_server_ts = 6;
|
||||
thread2b.event.origin_server_ts = 7;
|
||||
// (Note: in principle, we have the information needed to order these
|
||||
// events without using their timestamps, since they all came in via
|
||||
// addLiveEvents. In reality, some of them would have come in via the
|
||||
// /relations API, making it impossible to get the correct ordering
|
||||
// without MSC4033, which is why we fall back to timestamps. I.e. we
|
||||
// definitely could fix the code to make the above
|
||||
// timestamp-manipulation unnecessary, but it would only make this test
|
||||
// neater, not actually help in the real world.)
|
||||
|
||||
// When the receipts arrive
|
||||
room.addReceipt(createThreadedReceipt(readerId, main2, "main"));
|
||||
room.addReceipt(createThreadedReceipt(readerId, thread1a, main1Id));
|
||||
room.addReceipt(createReceipt(readerId, thread2a));
|
||||
room.addReceipt(createThreadedReceipt(readerId, thread2b, main2Id));
|
||||
|
||||
// Then we correctly identify that only main3 is unread
|
||||
expect(room.hasUserReadEvent(readerId, main1Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, main2Id)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, main3Id)).toBe(false);
|
||||
expect(room.hasUserReadEvent(readerId, thread1aId)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, thread1bId)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, thread2aId)).toBe(true);
|
||||
expect(room.hasUserReadEvent(readerId, thread2bId)).toBe(true);
|
||||
});
|
||||
|
||||
describe("dangling receipts", () => {
|
||||
it("reports unread if the unthreaded receipt is in a dangling state", () => {
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createReceipt(readerId, event));
|
||||
|
||||
// The event is not added in the room
|
||||
// So the receipt is in a dangling state
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// Add the event to the room
|
||||
// The receipt is removed from the dangling state
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// Then the event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports unread if the threaded receipt is in a dangling state", () => {
|
||||
const room = createRoom();
|
||||
const [root, rootId] = createEvent();
|
||||
const [event, eventId] = createThreadedEvent(root);
|
||||
setupThread(room, root);
|
||||
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
|
||||
|
||||
// The event is not added in the room
|
||||
// So the receipt is in a dangling state
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// Add the events to the room
|
||||
// The receipt is removed from the dangling state
|
||||
room.addLiveEvents([root, event]);
|
||||
|
||||
// Then the event is read
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple dangling receipts for the same event", () => {
|
||||
const room = createRoom();
|
||||
const [event, eventId] = createEvent();
|
||||
// When we receive a receipt for this event+user
|
||||
room.addReceipt(createReceipt(readerId, event));
|
||||
// We receive another receipt in the same event for another user
|
||||
room.addReceipt(createReceipt(otherUserId, event));
|
||||
|
||||
// The event is not added in the room
|
||||
// So the receipt is in a dangling state
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
|
||||
|
||||
// Add the event to the room
|
||||
// The two receipts should be processed
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
// Then the event is read
|
||||
// We expect that the receipt of `otherUserId` didn't replace/erase the receipt of `readerId`
|
||||
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createFakeClient(): MatrixClient {
|
||||
return {
|
||||
getUserId: jest.fn(),
|
||||
getEventMapper: jest.fn().mockReturnValue(jest.fn()),
|
||||
isInitialSyncComplete: jest.fn().mockReturnValue(true),
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
fetchRoomEvent: jest.fn().mockResolvedValue({}),
|
||||
paginateEventTimeline: jest.fn(),
|
||||
canSupport: { get: jest.fn() },
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
const senderId = "sender:s.ss";
|
||||
const readerId = "reader:r.rr";
|
||||
const otherUserId = "other:o.oo";
|
||||
|
||||
function createRoom(): Room {
|
||||
return new Room("!rid", createFakeClient(), "@u:s.nz", { timelineSupport: true });
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
function nextId(): string {
|
||||
return "$" + (idCounter++).toString(10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event and return it and its ID.
|
||||
*/
|
||||
function createEvent(): [MatrixEvent, string] {
|
||||
return createEventSentBy(senderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event with the supplied sender and return it and its ID.
|
||||
*/
|
||||
function createEventSentBy(customSenderId: string): [MatrixEvent, string] {
|
||||
const event = new MatrixEvent({ sender: customSenderId, event_id: nextId() });
|
||||
return [event, event.getId()!];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event in the thread of the supplied root and return it and its ID.
|
||||
*/
|
||||
function createThreadedEvent(root: MatrixEvent): [MatrixEvent, string] {
|
||||
const rootEventId = root.getId()!;
|
||||
const event = new MatrixEvent({
|
||||
sender: senderId,
|
||||
event_id: nextId(),
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
event_id: rootEventId,
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
["m.in_reply_to"]: {
|
||||
event_id: rootEventId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return [event, event.getId()!];
|
||||
}
|
||||
|
||||
function createReceipt(userId: string, referencedEvent: MatrixEvent): MatrixEvent {
|
||||
const content: ReceiptContent = {
|
||||
[referencedEvent.getId()!]: {
|
||||
"m.read": {
|
||||
[userId]: {
|
||||
ts: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
function createThreadedReceipt(userId: string, referencedEvent: MatrixEvent, threadId: string): MatrixEvent {
|
||||
const content: ReceiptContent = {
|
||||
[referencedEvent.getId()!]: {
|
||||
"m.read": {
|
||||
[userId]: {
|
||||
ts: 123,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeline in the timeline set that is not the live timeline.
|
||||
*/
|
||||
function createOldTimeline(room: Room, events: MatrixEvent[]) {
|
||||
const oldTimeline = room.getUnfilteredTimelineSet().addTimeline();
|
||||
room.getUnfilteredTimelineSet().addEventsToTimeline(events, true, oldTimeline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the hacks required for this room to create a thread based on the root
|
||||
* event supplied.
|
||||
*/
|
||||
function setupThread(room: Room, root: MatrixEvent) {
|
||||
const thread = room.createThread(root.getId()!, root, [root], false);
|
||||
thread.initialEventsFetched = true;
|
||||
}
|
||||
+132
-32
@@ -19,7 +19,7 @@ import { mocked } from "jest-mock";
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
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 { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
|
||||
@@ -149,20 +149,38 @@ describe("Thread", () => {
|
||||
});
|
||||
|
||||
it("considers other events with no RR as unread", () => {
|
||||
const { thread, events } = mkThread({
|
||||
// Given a long thread exists
|
||||
const { thread, events } = populateThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: [myUserId],
|
||||
authorId: "@other:foo.com",
|
||||
participantUserIds: ["@other:foo.com"],
|
||||
length: 25,
|
||||
ts: 190,
|
||||
});
|
||||
|
||||
// Before alice's last unthreaded receipt
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(1)!.getId() ?? "")).toBeTruthy();
|
||||
const event1 = events.at(1)!;
|
||||
const event2 = events.at(2)!;
|
||||
const event24 = events.at(24)!;
|
||||
|
||||
// After alice's last unthreaded receipt
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
|
||||
// And we have read the second message in it with an unthreaded receipt
|
||||
const receipt = new MatrixEvent({
|
||||
type: "m.receipt",
|
||||
room_id: room.roomId,
|
||||
content: {
|
||||
// unthreaded receipt for the second message in the thread
|
||||
[event2.getId()!]: {
|
||||
[ReceiptType.Read]: {
|
||||
[myUserId]: { ts: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
room.addReceipt(receipt);
|
||||
|
||||
// Then we have read the first message in the thread, and not the last
|
||||
expect(thread.hasUserReadEvent(myUserId, event1.getId()!)).toBe(true);
|
||||
expect(thread.hasUserReadEvent(myUserId, event24.getId()!)).toBe(false);
|
||||
});
|
||||
|
||||
it("considers event as read if there's a more recent unthreaded receipt", () => {
|
||||
@@ -481,13 +499,13 @@ describe("Thread", () => {
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, 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?.eventId).toEqual(message2.getId());
|
||||
expect(receipt?.data.ts).toEqual(200);
|
||||
expect(receipt?.data.thread_id).toEqual(thread.id);
|
||||
|
||||
// (And the receipt was synthetic)
|
||||
@@ -505,14 +523,14 @@ describe("Thread", () => {
|
||||
|
||||
// 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);
|
||||
const { thread, message1 } = await createThreadAnd2Events(client, 300, 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
|
||||
// Then the receipt is for the first message, because its
|
||||
// timestamp is later. 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());
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,11 +548,11 @@ describe("Thread", () => {
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.eventId).toEqual(message2.getId());
|
||||
});
|
||||
|
||||
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
|
||||
@@ -550,22 +568,24 @@ describe("Thread", () => {
|
||||
|
||||
// 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);
|
||||
const { thread, message2 } = await createThreadAnd2Events(client, 300, 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.
|
||||
// Then a receipt was added for the last message, even though it
|
||||
// has lower ts, 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());
|
||||
expect(receipt?.eventId).toEqual(message2.getId());
|
||||
});
|
||||
});
|
||||
|
||||
async function createThreadAndEvent(
|
||||
async function createThreadAnd2Events(
|
||||
client: MatrixClient,
|
||||
rootTs: number,
|
||||
eventTs: number,
|
||||
message1Ts: number,
|
||||
message2Ts: number,
|
||||
userId: string,
|
||||
): Promise<{ thread: Thread; message: MatrixEvent }> {
|
||||
): Promise<{ thread: Thread; message1: MatrixEvent; message2: MatrixEvent }> {
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread
|
||||
@@ -576,24 +596,41 @@ describe("Thread", () => {
|
||||
participantUserIds: [],
|
||||
ts: rootTs,
|
||||
});
|
||||
// Sanity: the current receipt is for the thread root
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
// Sanity: there is no read receipt on the thread yet because the
|
||||
// thread events don't get properly added to the room by mkThread.
|
||||
expect(thread.getReadReceiptForUserId(userId)).toBeNull();
|
||||
|
||||
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({
|
||||
// Add a message with ts message1Ts
|
||||
const message1 = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: eventTs,
|
||||
ts: message1Ts,
|
||||
});
|
||||
await thread.addEvent(message, false, true);
|
||||
await thread.addEvent(message1, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message };
|
||||
// Sanity: the thread now has a properly-added event, so this event
|
||||
// has a synthetic receipt.
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
|
||||
|
||||
// Add a message with ts message2Ts
|
||||
const message2 = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: message2Ts,
|
||||
});
|
||||
await thread.addEvent(message2, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message1, message2 };
|
||||
}
|
||||
|
||||
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
|
||||
@@ -675,6 +712,69 @@ describe("Thread", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEvent", () => {
|
||||
describe("Given server support for threads", () => {
|
||||
let previousThreadHasServerSideSupport: FeatureSupport;
|
||||
|
||||
beforeAll(() => {
|
||||
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
|
||||
});
|
||||
|
||||
it("Adds events even if they appear out of order", async () => {
|
||||
// Given a thread exists
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
const prevNumEvents = thread.timeline.length;
|
||||
|
||||
// When two messages come in but the later one has an older timestamp
|
||||
const message1 = createThreadMessage(thread.id, user, room, "message1");
|
||||
const message2 = createThreadMessage(thread.id, user, room, "message2");
|
||||
message2.localTimestamp -= 10000;
|
||||
|
||||
await thread.addEvent(message1, false);
|
||||
await thread.addEvent(message2, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
expect(thread.timeline.length - prevNumEvents).toEqual(2);
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(message2);
|
||||
expect(secondLastEvent).toBe(message1);
|
||||
});
|
||||
|
||||
it("Adds events to start even if they appear out of order", async () => {
|
||||
// Given a thread exists
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
const prevNumEvents = thread.timeline.length;
|
||||
|
||||
// When two messages come in but the later one has an older timestamp
|
||||
const message1 = createThreadMessage(thread.id, user, room, "message1");
|
||||
const message2 = createThreadMessage(thread.id, user, room, "message2");
|
||||
message2.localTimestamp -= 10000;
|
||||
|
||||
await thread.addEvent(message1, false);
|
||||
await thread.addEvent(message2, true);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
expect(thread.timeline.length - prevNumEvents).toEqual(2);
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const firstEvent = thread.timeline.at(0)!;
|
||||
expect(lastEvent).toBe(message1);
|
||||
expect(firstEvent).toBe(message2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,8 @@ describe("fixNotificationCountOnDecryption", () => {
|
||||
mockClient,
|
||||
);
|
||||
|
||||
room.addLiveEvents([event]);
|
||||
|
||||
THREAD_ID = event.getId()!;
|
||||
threadEvent = mkEvent({
|
||||
type: EventType.RoomMessage,
|
||||
|
||||
@@ -134,6 +134,25 @@ describe("oidc authorization", () => {
|
||||
|
||||
expect(authUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should generate url with create prompt", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const metadata = delegatedAuthConfig.metadata;
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
prompt: "create",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("prompt")).toEqual("create");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
@@ -284,6 +303,7 @@ describe("oidc authorization", () => {
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
idTokenClaims: result.idTokenClaims,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,6 +345,7 @@ describe("oidc authorization", () => {
|
||||
expires_at: result.tokenResponse.expires_at,
|
||||
scope,
|
||||
},
|
||||
idTokenClaims: result.idTokenClaims,
|
||||
});
|
||||
|
||||
expect(result.tokenResponse.token_type).toEqual("Bearer");
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @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 { OidcTokenRefresher } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||
|
||||
describe("OidcTokenRefresher", () => {
|
||||
// OidcTokenRefresher props
|
||||
// see class declaration for info
|
||||
const authConfig = {
|
||||
issuer: "https://issuer.org/",
|
||||
};
|
||||
const clientId = "test-client-id";
|
||||
const redirectUri = "https://test.org";
|
||||
const deviceId = "abc123";
|
||||
const idTokenClaims = {
|
||||
exp: Date.now() / 1000 + 100000,
|
||||
aud: clientId,
|
||||
iss: authConfig.issuer,
|
||||
sub: "123",
|
||||
iat: 123,
|
||||
};
|
||||
// used to mock a valid token response, as consumed by OidcClient library
|
||||
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||
|
||||
// auth config used in mocked calls to OP .well-known
|
||||
const config = makeDelegatedAuthConfig(authConfig.issuer);
|
||||
|
||||
const makeTokenResponse = (accessToken: string, refreshToken?: string) => ({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 300,
|
||||
scope: scope,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
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: [],
|
||||
});
|
||||
|
||||
fetchMock.post(config.metadata.token_endpoint, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("new-access-token", "new-refresh-token"),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
fetchMock.resetBehavior();
|
||||
});
|
||||
|
||||
it("throws when oidc client cannot be initialised", async () => {
|
||||
jest.spyOn(logger, "error");
|
||||
fetchMock.get(
|
||||
`${config.metadata.issuer}.well-known/openid-configuration`,
|
||||
{
|
||||
ok: false,
|
||||
status: 404,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.oidcClientReady).rejects.toThrow();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"Failed to initialise OIDC client.",
|
||||
// error from OidcClient
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("initialises oidc client", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
// @ts-ignore peek at private property to see we initialised the client correctly
|
||||
expect(refresher.oidcClient.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
authority: authConfig.issuer,
|
||||
scope,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("doRefreshAccessToken()", () => {
|
||||
it("should throw when oidcClient has not been initialised", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
|
||||
"Cannot get new token before OIDC client is initialised.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh the tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
const result = await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// spy on our stub
|
||||
jest.spyOn(refresher, "persistTokens");
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
fetchMock
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
)
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// reset call counts
|
||||
fetchMock.resetHistory();
|
||||
|
||||
const refreshToken = "refresh-token";
|
||||
const first = refresher.doRefreshAccessToken(refreshToken);
|
||||
const second = refresher.doRefreshAccessToken(refreshToken);
|
||||
|
||||
const result1 = await second;
|
||||
const result2 = await first;
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
// call again after first request resolves
|
||||
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(third).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
fetchMock.post(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should make fresh request after a failed request", async () => {
|
||||
// make sure inflight request is cleared after a failure
|
||||
fetchMock
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
)
|
||||
.postOnce(
|
||||
config.metadata.token_endpoint,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||
},
|
||||
{ overwriteRoutes: false },
|
||||
);
|
||||
|
||||
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||
await refresher.oidcClientReady;
|
||||
// reset call counts
|
||||
fetchMock.resetHistory();
|
||||
|
||||
// first call fails
|
||||
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||
|
||||
// call again after first request resolves
|
||||
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||
|
||||
// called token endpoint, got new tokens
|
||||
expect(result).toEqual({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
});
|
||||
const mockRoom = {
|
||||
updatePendingEvent: jest.fn(),
|
||||
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
|
||||
} as unknown as Room;
|
||||
client.resendEvent(dummyEvent, mockRoom);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 { decodeBase64 } from "../../src/base64";
|
||||
import {
|
||||
randomLowercaseString,
|
||||
randomString,
|
||||
randomUppercaseString,
|
||||
secureRandomBase64Url,
|
||||
} from "../../src/randomstring";
|
||||
|
||||
describe("Random strings", () => {
|
||||
it.each([8, 16, 32])("secureRandomBase64 generates %i valid base64 bytes", (n: number) => {
|
||||
const randb641 = secureRandomBase64Url(n);
|
||||
const randb642 = secureRandomBase64Url(n);
|
||||
|
||||
expect(randb641).not.toEqual(randb642);
|
||||
|
||||
const decoded = decodeBase64(randb641);
|
||||
expect(decoded).toHaveLength(n);
|
||||
});
|
||||
|
||||
it.each([8, 16, 32])("randomString generates string of %i characters", (n: number) => {
|
||||
const rand1 = randomString(n);
|
||||
const rand2 = randomString(n);
|
||||
|
||||
expect(rand1).not.toEqual(rand2);
|
||||
|
||||
expect(rand1).toHaveLength(n);
|
||||
});
|
||||
|
||||
it.each([8, 16, 32])("randomLowercaseString generates lowercase string of %i characters", (n: number) => {
|
||||
const rand1 = randomLowercaseString(n);
|
||||
const rand2 = randomLowercaseString(n);
|
||||
|
||||
expect(rand1).not.toEqual(rand2);
|
||||
|
||||
expect(rand1).toHaveLength(n);
|
||||
|
||||
expect(rand1.toLowerCase()).toEqual(rand1);
|
||||
});
|
||||
|
||||
it.each([8, 16, 32])("randomUppercaseString generates lowercase string of %i characters", (n: number) => {
|
||||
const rand1 = randomUppercaseString(n);
|
||||
const rand2 = randomUppercaseString(n);
|
||||
|
||||
expect(rand1).not.toEqual(rand2);
|
||||
|
||||
expect(rand1).toHaveLength(n);
|
||||
|
||||
expect(rand1.toUpperCase()).toEqual(rand1);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType, WrappedReceipt } from "../../src/@types/read_receipts";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { EventType, MatrixEvent, Room } from "../../src/matrix";
|
||||
import { EventType, MatrixEvent, RelationType, Room, threadIdForReceipt } from "../../src/matrix";
|
||||
import { synthesizeReceipt } from "../../src/models/read-receipt";
|
||||
import { encodeUri } from "../../src/utils";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -43,8 +43,10 @@ const THREAD_ID = "$thread_event_id";
|
||||
const ROOM_ID = "!123:matrix.org";
|
||||
|
||||
describe("Read receipt", () => {
|
||||
let threadRoot: MatrixEvent;
|
||||
let threadEvent: MatrixEvent;
|
||||
let roomEvent: MatrixEvent;
|
||||
let editOfThreadRoot: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
httpBackend = new MockHttpBackend();
|
||||
@@ -57,6 +59,15 @@ describe("Read receipt", () => {
|
||||
client.isGuest = () => false;
|
||||
client.supportsThreads = () => true;
|
||||
|
||||
threadRoot = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: ROOM_ID,
|
||||
content: { body: "This is the thread root" },
|
||||
});
|
||||
threadRoot.event.event_id = THREAD_ID;
|
||||
|
||||
threadEvent = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
@@ -82,6 +93,9 @@ describe("Read receipt", () => {
|
||||
body: "Hello from a room",
|
||||
},
|
||||
});
|
||||
|
||||
editOfThreadRoot = utils.mkEdit(threadRoot, client, "@bob:matrix.org", ROOM_ID);
|
||||
editOfThreadRoot.setThreadId(THREAD_ID);
|
||||
});
|
||||
|
||||
describe("sendReceipt", () => {
|
||||
@@ -208,6 +222,7 @@ describe("Read receipt", () => {
|
||||
it.each([
|
||||
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
|
||||
{ getEvent: () => threadEvent, destinationId: THREAD_ID },
|
||||
{ getEvent: () => editOfThreadRoot, destinationId: MAIN_ROOM_TIMELINE },
|
||||
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
|
||||
const event = getEvent();
|
||||
const userId = "@bob:example.org";
|
||||
@@ -225,6 +240,7 @@ describe("Read receipt", () => {
|
||||
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);
|
||||
room.findEventById = jest.fn().mockReturnValue({} as MatrixEvent);
|
||||
|
||||
const unthreadedReceipt: WrappedReceipt = {
|
||||
eventId: "$olderEvent",
|
||||
@@ -258,4 +274,200 @@ describe("Read receipt", () => {
|
||||
expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Determining the right thread ID for a receipt", () => {
|
||||
it("provides the thread root ID for a normal threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"body": "Hello from a thread",
|
||||
"m.relates_to": {
|
||||
"event_id": "$thread1",
|
||||
"m.in_reply_to": {
|
||||
event_id: "$thread1",
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("provides 'main' for a non-thread message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: { body: "Hello" },
|
||||
});
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: { body: "Hello" },
|
||||
});
|
||||
// Set thread ID to this event's ID, meaning this is the thread root
|
||||
event.setThreadId(event.getId());
|
||||
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a reaction to a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$thread1",
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set thread Id, meaning this looks like it's in the thread (this
|
||||
// happens for relations like this, so that they appear in the
|
||||
// thread's timeline).
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// But because it's a reaction to the thread root, it's in main
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides the thread ID for a reaction to a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: "@bob:matrix.org",
|
||||
room: "!roomx",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$withinthread2",
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread. This happens
|
||||
// when the message arrived and is classified.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread because it refers to something else, not the
|
||||
// thread root
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("(suprisingly?) provides 'main' for a redaction of a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
reason: "Spamming",
|
||||
},
|
||||
redacts: "$withinthread2",
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// Because redacting a message removes all its m.relations, the
|
||||
// message is no longer in the thread, so we must send a receipt for
|
||||
// it in the main timeline.
|
||||
//
|
||||
// This is surprising, but it follows the spec (at least up to
|
||||
// current latest room version, 11). In fact, the event should no
|
||||
// longer have a thread ID set on it, so this testcase should not
|
||||
// come up. (At time of writing, this is not the case though - it
|
||||
// does still have threadId set.)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides the thread ID for an edit of a threaded message", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
"body": "Edited!",
|
||||
"m.new_content": {
|
||||
body: "Edited!",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: "$withinthread2",
|
||||
},
|
||||
},
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("$thread1");
|
||||
});
|
||||
|
||||
it("provides 'main' for an edit of a thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
"body": "Edited!",
|
||||
"m.new_content": {
|
||||
body: "Edited!",
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: "$thread1",
|
||||
},
|
||||
},
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
|
||||
it("provides 'main' for a redaction of the thread root", () => {
|
||||
const event = utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
reason: "Spamming",
|
||||
},
|
||||
redacts: "$thread1",
|
||||
room: "!roomx",
|
||||
user: "@bob:matrix.org",
|
||||
});
|
||||
|
||||
// Set thread Id, to say this message is in the thread.
|
||||
event.setThreadId("$thread1");
|
||||
|
||||
// It's in the thread, because it redacts something inside the
|
||||
// thread (not the thread root)
|
||||
expect(threadIdForReceipt(event)).toEqual("main");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,10 @@ export class DummyTransport<D extends RendezvousTransportDetails, T> implements
|
||||
ready = false;
|
||||
cancelled = false;
|
||||
|
||||
constructor(private name: string, private mockDetails: D) {}
|
||||
constructor(
|
||||
private name: string,
|
||||
private mockDetails: D,
|
||||
) {}
|
||||
onCancelled?: RendezvousFailureListener;
|
||||
|
||||
details(): Promise<RendezvousTransportDetails> {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import "../../olm-loader";
|
||||
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
|
||||
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
|
||||
function makeTransport(name: string) {
|
||||
|
||||
@@ -23,24 +23,65 @@ import {
|
||||
MSC3903ECDHPayload,
|
||||
MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel,
|
||||
} from "../../../src/rendezvous/channels";
|
||||
import { MatrixClient } from "../../../src";
|
||||
import { Device, MatrixClient } from "../../../src";
|
||||
import {
|
||||
MSC3886SimpleHttpRendezvousTransport,
|
||||
MSC3886SimpleHttpRendezvousTransportDetails,
|
||||
} from "../../../src/rendezvous/transports";
|
||||
import { DummyTransport } from "./DummyTransport";
|
||||
import { decodeBase64 } from "../../../src/crypto/olmlib";
|
||||
import { decodeBase64 } from "../../../src/base64";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { CrossSigningKey, OwnDeviceKeys } from "../../../src/crypto-api";
|
||||
|
||||
type UserID = string;
|
||||
type DeviceID = string;
|
||||
type Fingerprint = string;
|
||||
type SimpleDeviceMap = Record<UserID, Record<DeviceID, Fingerprint>>;
|
||||
|
||||
function mockDevice(userId: UserID, deviceId: DeviceID, fingerprint: Fingerprint): Device {
|
||||
return {
|
||||
deviceId,
|
||||
userId,
|
||||
getFingerprint: () => fingerprint,
|
||||
} as unknown as Device;
|
||||
}
|
||||
|
||||
function mockDeviceMap(
|
||||
userId: UserID,
|
||||
deviceId: DeviceID,
|
||||
deviceKey?: Fingerprint,
|
||||
otherDevices: SimpleDeviceMap = {},
|
||||
): Map<string, Map<string, Device>> {
|
||||
const deviceMap: Map<string, Map<string, Device>> = new Map();
|
||||
|
||||
const myDevices: Map<string, Device> = new Map();
|
||||
if (deviceKey) {
|
||||
myDevices.set(deviceId, mockDevice(userId, deviceId, deviceKey));
|
||||
}
|
||||
deviceMap.set(userId, myDevices);
|
||||
|
||||
for (const u in otherDevices) {
|
||||
let userDevices = deviceMap.get(u);
|
||||
if (!userDevices) {
|
||||
userDevices = new Map();
|
||||
deviceMap.set(u, userDevices);
|
||||
}
|
||||
for (const d in otherDevices[u]) {
|
||||
userDevices.set(d, mockDevice(u, d, otherDevices[u][d]));
|
||||
}
|
||||
}
|
||||
|
||||
return deviceMap;
|
||||
}
|
||||
|
||||
function makeMockClient(opts: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
deviceKey?: string;
|
||||
msc3882Enabled: boolean;
|
||||
userId: UserID;
|
||||
deviceId: DeviceID;
|
||||
deviceKey?: Fingerprint;
|
||||
getLoginTokenEnabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
msc3886Enabled: boolean;
|
||||
devices?: Record<string, Partial<DeviceInfo>>;
|
||||
devices?: SimpleDeviceMap;
|
||||
verificationFunction?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
@@ -48,50 +89,77 @@ function makeMockClient(opts: {
|
||||
blocked: boolean,
|
||||
known: boolean,
|
||||
) => void;
|
||||
crossSigningIds?: Record<string, string>;
|
||||
}): MatrixClient {
|
||||
return {
|
||||
getVersions() {
|
||||
return {
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": opts.msc3882Enabled,
|
||||
"org.matrix.msc3886": opts.msc3886Enabled,
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
capabilities: {
|
||||
"org.matrix.msc3882.get_login_token": {
|
||||
enabled: opts.msc3882Enabled,
|
||||
crossSigningIds?: Partial<Record<CrossSigningKey, string>>;
|
||||
}): [MatrixClient, Map<string, Map<string, Device>>] {
|
||||
const deviceMap = mockDeviceMap(opts.userId, opts.deviceId, opts.deviceKey, opts.devices);
|
||||
return [
|
||||
{
|
||||
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
|
||||
if (feature === "org.matrix.msc3886") {
|
||||
return opts.msc3886Enabled;
|
||||
} else if (feature === "org.matrix.msc3882") {
|
||||
return opts.getLoginTokenEnabled;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
getVersions() {
|
||||
return {
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": opts.getLoginTokenEnabled,
|
||||
"org.matrix.msc3886": opts.msc3886Enabled,
|
||||
},
|
||||
};
|
||||
},
|
||||
getCapabilities() {
|
||||
return opts.msc3882r0Only
|
||||
? {}
|
||||
: {
|
||||
capabilities: {
|
||||
"m.get_login_token": {
|
||||
enabled: opts.getLoginTokenEnabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
getDeviceId() {
|
||||
return opts.deviceId;
|
||||
},
|
||||
getDeviceEd25519Key() {
|
||||
return opts.deviceKey;
|
||||
},
|
||||
baseUrl: "https://example.com",
|
||||
crypto: {
|
||||
getStoredDevice(userId: string, deviceId: string) {
|
||||
return opts.devices?.[deviceId] ?? null;
|
||||
};
|
||||
},
|
||||
setDeviceVerification: opts.verificationFunction,
|
||||
crossSigningInfo: {
|
||||
getId(key: string) {
|
||||
return opts.crossSigningIds?.[key];
|
||||
},
|
||||
getUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
},
|
||||
} as unknown as MatrixClient;
|
||||
getSafeUserId() {
|
||||
return opts.userId;
|
||||
},
|
||||
getDeviceId() {
|
||||
return opts.deviceId;
|
||||
},
|
||||
baseUrl: "https://example.com",
|
||||
getCrypto() {
|
||||
return {
|
||||
getUserDeviceInfo(
|
||||
[userId]: string[],
|
||||
downloadUncached?: boolean,
|
||||
): Promise<Map<string, Map<string, Device>>> {
|
||||
return Promise.resolve(deviceMap);
|
||||
},
|
||||
getCrossSigningKeyId(key: CrossSigningKey): string | null {
|
||||
return opts.crossSigningIds?.[key] ?? null;
|
||||
},
|
||||
setDeviceVerified(userId: string, deviceId: string, verified: boolean): Promise<void> {
|
||||
return Promise.resolve();
|
||||
},
|
||||
crossSignDevice(deviceId: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
},
|
||||
getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
|
||||
return Promise.resolve({
|
||||
ed25519: opts.deviceKey!,
|
||||
curve25519: "aaaa",
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as MatrixClient,
|
||||
deviceMap,
|
||||
];
|
||||
}
|
||||
|
||||
function makeTransport(name: string, uri = "https://test.rz/123456") {
|
||||
@@ -106,6 +174,7 @@ describe("Rendezvous", function () {
|
||||
let httpBackend: MockHttpBackend;
|
||||
let fetchFn: typeof global.fetch;
|
||||
let transports: DummyTransport<any, MSC3903ECDHPayload>[];
|
||||
const userId: UserID = "@user:example.com";
|
||||
|
||||
beforeEach(function () {
|
||||
httpBackend = new MockHttpBackend();
|
||||
@@ -118,11 +187,11 @@ describe("Rendezvous", function () {
|
||||
});
|
||||
|
||||
it("generate and cancel", async function () {
|
||||
const alice = makeMockClient({
|
||||
userId: "@alice:example.com",
|
||||
deviceId: "DEVICEID",
|
||||
const [alice] = makeMockClient({
|
||||
userId,
|
||||
deviceId: "ALICE",
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: true,
|
||||
});
|
||||
httpBackend.when("POST", "https://fallbackserver/rz").response = {
|
||||
@@ -180,10 +249,10 @@ describe("Rendezvous", function () {
|
||||
});
|
||||
|
||||
async function testNoProtocols({
|
||||
msc3882Enabled,
|
||||
getLoginTokenEnabled,
|
||||
msc3882r0Only,
|
||||
}: {
|
||||
msc3882Enabled: boolean;
|
||||
getLoginTokenEnabled: boolean;
|
||||
msc3882r0Only: boolean;
|
||||
}) {
|
||||
const aliceTransport = makeTransport("Alice");
|
||||
@@ -194,11 +263,11 @@ describe("Rendezvous", function () {
|
||||
|
||||
// alice is already signs in and generates a code
|
||||
const aliceOnFailure = jest.fn();
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
const [alice] = makeMockClient({
|
||||
userId,
|
||||
deviceId: "ALICE",
|
||||
msc3886Enabled: false,
|
||||
msc3882Enabled,
|
||||
getLoginTokenEnabled,
|
||||
msc3882r0Only,
|
||||
});
|
||||
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
|
||||
@@ -241,11 +310,11 @@ describe("Rendezvous", function () {
|
||||
}
|
||||
|
||||
it("no protocols - r0", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: true });
|
||||
await testNoProtocols({ getLoginTokenEnabled: false, msc3882r0Only: true });
|
||||
});
|
||||
|
||||
it("no protocols - r1", async function () {
|
||||
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: false });
|
||||
it("no protocols - stable", async function () {
|
||||
await testNoProtocols({ getLoginTokenEnabled: false, msc3882r0Only: false });
|
||||
});
|
||||
|
||||
it("new device declines protocol with outcome unsupported", async function () {
|
||||
@@ -257,10 +326,10 @@ describe("Rendezvous", function () {
|
||||
|
||||
// alice is already signs in and generates a code
|
||||
const aliceOnFailure = jest.fn();
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
const [alice] = makeMockClient({
|
||||
userId,
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -316,10 +385,10 @@ describe("Rendezvous", function () {
|
||||
|
||||
// alice is already signs in and generates a code
|
||||
const aliceOnFailure = jest.fn();
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
const [alice] = makeMockClient({
|
||||
userId,
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -375,10 +444,10 @@ describe("Rendezvous", function () {
|
||||
|
||||
// alice is already signs in and generates a code
|
||||
const aliceOnFailure = jest.fn();
|
||||
const alice = makeMockClient({
|
||||
const [alice] = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -436,10 +505,10 @@ describe("Rendezvous", function () {
|
||||
|
||||
// alice is already signs in and generates a code
|
||||
const aliceOnFailure = jest.fn();
|
||||
const alice = makeMockClient({
|
||||
const [alice] = makeMockClient({
|
||||
userId: "alice",
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
});
|
||||
@@ -495,7 +564,7 @@ describe("Rendezvous", function () {
|
||||
await bobCompleteProm;
|
||||
});
|
||||
|
||||
async function completeLogin(devices: Record<string, Partial<DeviceInfo>>) {
|
||||
async function completeLogin(devices: SimpleDeviceMap) {
|
||||
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
|
||||
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
|
||||
transports.push(aliceTransport, bobTransport);
|
||||
@@ -505,10 +574,10 @@ describe("Rendezvous", function () {
|
||||
// alice is already signs in and generates a code
|
||||
const aliceOnFailure = jest.fn();
|
||||
const aliceVerification = jest.fn();
|
||||
const alice = makeMockClient({
|
||||
userId: "alice",
|
||||
const [alice, deviceMap] = makeMockClient({
|
||||
userId,
|
||||
deviceId: "ALICE",
|
||||
msc3882Enabled: true,
|
||||
getLoginTokenEnabled: true,
|
||||
msc3882r0Only: false,
|
||||
msc3886Enabled: false,
|
||||
devices,
|
||||
@@ -575,13 +644,14 @@ describe("Rendezvous", function () {
|
||||
aliceRz,
|
||||
bobTransport,
|
||||
bobEcdh,
|
||||
deviceMap,
|
||||
};
|
||||
}
|
||||
|
||||
it("approve on existing device + verification", async function () {
|
||||
const { bobEcdh, aliceRz } = await completeLogin({
|
||||
BOB: {
|
||||
getFingerprint: () => "bbbb",
|
||||
[userId]: {
|
||||
BOB: "bbbb",
|
||||
},
|
||||
});
|
||||
const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice();
|
||||
@@ -607,33 +677,29 @@ describe("Rendezvous", function () {
|
||||
});
|
||||
|
||||
it("device appears online within timeout", async function () {
|
||||
const devices: Record<string, Partial<DeviceInfo>> = {};
|
||||
const { aliceRz } = await completeLogin(devices);
|
||||
// device appears after 1 second
|
||||
const devices: SimpleDeviceMap = {};
|
||||
const { aliceRz, deviceMap } = await completeLogin(devices);
|
||||
// device appears before the timeout
|
||||
setTimeout(() => {
|
||||
devices.BOB = {
|
||||
getFingerprint: () => "bbbb",
|
||||
};
|
||||
deviceMap.get(userId)!.set("BOB", mockDevice(userId, "BOB", "bbbb"));
|
||||
}, 1000);
|
||||
await aliceRz.verifyNewDeviceOnExistingDevice(2000);
|
||||
});
|
||||
|
||||
it("device appears online after timeout", async function () {
|
||||
const devices: Record<string, Partial<DeviceInfo>> = {};
|
||||
const { aliceRz } = await completeLogin(devices);
|
||||
// device appears after 1 second
|
||||
const devices: SimpleDeviceMap = {};
|
||||
const { aliceRz, deviceMap } = await completeLogin(devices);
|
||||
// device appears after the timeout
|
||||
setTimeout(() => {
|
||||
devices.BOB = {
|
||||
getFingerprint: () => "bbbb",
|
||||
};
|
||||
deviceMap.get(userId)!.set("BOB", mockDevice(userId, "BOB", "bbbb"));
|
||||
}, 1500);
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("mismatched device key", async function () {
|
||||
const { aliceRz } = await completeLogin({
|
||||
BOB: {
|
||||
getFingerprint: () => "XXXX",
|
||||
[userId]: {
|
||||
BOB: "XXXX",
|
||||
},
|
||||
});
|
||||
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { DecryptionError } from "../../src/crypto/algorithms";
|
||||
import { defer } from "../../src/utils";
|
||||
import { Room } from "../../src/models/room";
|
||||
|
||||
describe("RoomState", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -362,9 +363,11 @@ describe("RoomState", function () {
|
||||
});
|
||||
|
||||
it("does not add redacted beacon info events to state", () => {
|
||||
const mockClient = {} as unknown as MockedObject<MatrixClient>;
|
||||
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId);
|
||||
const redactionEvent = new MatrixEvent({ type: "m.room.redaction" });
|
||||
redactedBeaconEvent.makeRedacted(redactionEvent);
|
||||
const room = new Room(roomId, mockClient, userA);
|
||||
redactedBeaconEvent.makeRedacted(redactionEvent, room);
|
||||
const emitSpy = jest.spyOn(state, "emit");
|
||||
|
||||
state.setStateEvents([redactedBeaconEvent]);
|
||||
@@ -394,11 +397,13 @@ describe("RoomState", function () {
|
||||
});
|
||||
|
||||
it("destroys and removes redacted beacon events", () => {
|
||||
const mockClient = {} as unknown as MockedObject<MatrixClient>;
|
||||
const beaconId = "$beacon1";
|
||||
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
|
||||
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
|
||||
const redactionEvent = new MatrixEvent({ type: "m.room.redaction", redacts: beaconEvent.getId() });
|
||||
redactedBeaconEvent.makeRedacted(redactionEvent);
|
||||
const room = new Room(roomId, mockClient, userA);
|
||||
redactedBeaconEvent.makeRedacted(redactionEvent, room);
|
||||
|
||||
state.setStateEvents([beaconEvent]);
|
||||
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
|
||||
|
||||
+235
-89
@@ -1460,7 +1460,7 @@ describe("Room", function () {
|
||||
it("should reset the unread count when our non-synthetic receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
@@ -1479,7 +1479,7 @@ describe("Room", function () {
|
||||
it("should not reset the unread count when someone else's receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
@@ -1498,7 +1498,7 @@ describe("Room", function () {
|
||||
it("should not reset the unread count when our non-synthetic receipt points to an earlier event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
@@ -1517,7 +1517,7 @@ describe("Room", function () {
|
||||
it("should not reset the unread count when our a synthetic receipt points to the latest event", () => {
|
||||
// Given a room with 2 events, and an unread count set.
|
||||
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
|
||||
room.timeline = [event1, event2];
|
||||
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
|
||||
room.setUnread(NotificationCountType.Total, 45);
|
||||
room.setUnread(NotificationCountType.Highlight, 57);
|
||||
// Sanity check:
|
||||
@@ -1743,12 +1743,70 @@ describe("Room", function () {
|
||||
});
|
||||
|
||||
describe("hasUserReadUpTo", function () {
|
||||
it("should acknowledge if an event has been read", function () {
|
||||
it("returns true if there is a receipt for this event (main timeline)", function () {
|
||||
const ts = 13787898424;
|
||||
room.addLiveEvents([eventToAck]);
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
|
||||
room.findEventById = jest.fn().mockReturnValue({ getThread: jest.fn() } as unknown as MatrixEvent);
|
||||
expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true);
|
||||
});
|
||||
it("return false for an unknown event", function () {
|
||||
|
||||
it("returns true if there is a receipt for a later event (main timeline)", async function () {
|
||||
// Given some events exist in the room
|
||||
const events: MatrixEvent[] = [
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "1111",
|
||||
event: true,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "2222",
|
||||
event: true,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "3333",
|
||||
event: true,
|
||||
}),
|
||||
];
|
||||
await room.addLiveEvents(events);
|
||||
|
||||
// When I add a receipt for the latest one
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
|
||||
|
||||
// Then the older ones are read too
|
||||
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
|
||||
expect(room.hasUserReadEvent(userB, events[1].getId()!)).toEqual(true);
|
||||
});
|
||||
|
||||
describe("threads enabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(room.client, "supportsThreads").mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns true if there is an unthreaded receipt for a later event in a thread", async () => {
|
||||
// Given a thread exists in the room
|
||||
const { thread, events } = mkThread({ room, length: 3 });
|
||||
thread.initialEventsFetched = true;
|
||||
await room.addLiveEvents(events);
|
||||
|
||||
// When I add an unthreaded receipt for the latest thread message
|
||||
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
|
||||
|
||||
// Then the main timeline message is read
|
||||
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false for an unknown event", function () {
|
||||
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -3147,106 +3205,194 @@ describe("Room", function () {
|
||||
const client = new TestClient(userA).client;
|
||||
const room = new Room(roomId, client, userA);
|
||||
|
||||
it("handles missing receipt type", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
return receiptType === ReceiptType.ReadPrivate ? ({ eventId: "eventId" } as WrappedReceipt) : null;
|
||||
};
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual("eventId");
|
||||
});
|
||||
|
||||
describe("prefers newer receipt", () => {
|
||||
it("should compare correctly using timelines", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1" } as WrappedReceipt;
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2" } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: (event1, event2) => {
|
||||
return event1 === `eventId${i}` ? 1 : -1;
|
||||
},
|
||||
} as EventTimelineSet);
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
|
||||
}
|
||||
describe("invalid receipts", () => {
|
||||
beforeEach(() => {
|
||||
// Clear the spies on logger.warn
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("correctly compares by timestamp", () => {
|
||||
it("should correctly compare, if we have all receipts", () => {
|
||||
it("ignores receipts pointing at missing events", () => {
|
||||
// Given a receipt exists
|
||||
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
|
||||
return { eventId: "missingEventId" } as WrappedReceipt;
|
||||
};
|
||||
// But the event ID it contains does not refer to an event we have
|
||||
room.findEventById = jest.fn().mockReturnValue(null);
|
||||
|
||||
// When we ask what they have read
|
||||
// Then we say "nothing"
|
||||
expect(room.getEventReadUpTo(userA)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores receipts pointing at the wrong thread", () => {
|
||||
// Given a threaded receipt exists
|
||||
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
|
||||
return { eventId: "wrongThreadEventId", data: { ts: 0, thread_id: "thread1" } } as WrappedReceipt;
|
||||
};
|
||||
// But the event it refers to is in a thread
|
||||
room.findEventById = jest.fn().mockReturnValue({ threadRootId: "thread2" } as MatrixEvent);
|
||||
|
||||
// When we ask what they have read
|
||||
// Then we say "nothing"
|
||||
expect(room.getEventReadUpTo(userA)).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"Ignoring receipt because its thread_id (thread1) disagrees with the thread root (thread2) " +
|
||||
"of the referenced event (event ID = wrongThreadEventId)",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts unthreaded receipts pointing at an event in a thread", () => {
|
||||
// Given an unthreaded receipt exists
|
||||
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
|
||||
return { eventId: "inThreadEventId" } as WrappedReceipt;
|
||||
};
|
||||
// And the event it refers to is in a thread
|
||||
room.findEventById = jest.fn().mockReturnValue({ threadRootId: "thread2" } as MatrixEvent);
|
||||
|
||||
// When we ask what they have read
|
||||
// Then we say the event
|
||||
expect(room.getEventReadUpTo(userA)).toEqual("inThreadEventId");
|
||||
});
|
||||
|
||||
it("accepts main thread receipts pointing at an event in main timeline", () => {
|
||||
// Given a threaded receipt exists, in main thread
|
||||
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
|
||||
return { eventId: "mainThreadEventId", data: { ts: 12, thread_id: "main" } } as WrappedReceipt;
|
||||
};
|
||||
// And the event it refers to is in a thread
|
||||
room.findEventById = jest.fn().mockReturnValue({ threadRootId: undefined } as MatrixEvent);
|
||||
|
||||
// When we ask what they have read
|
||||
// Then we say the event
|
||||
expect(room.getEventReadUpTo(userA)).toEqual("mainThreadEventId");
|
||||
});
|
||||
|
||||
it("accepts main thread receipts pointing at a thread root", () => {
|
||||
// Given a threaded receipt exists, in main thread
|
||||
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
|
||||
return { eventId: "rootId", data: { ts: 12, thread_id: "main" } } as WrappedReceipt;
|
||||
};
|
||||
// And the event it refers to is in a thread, because it is a thread root
|
||||
room.findEventById = jest
|
||||
.fn()
|
||||
.mockReturnValue({ isThreadRoot: true, threadRootId: "thread1" } as MatrixEvent);
|
||||
|
||||
// When we ask what they have read
|
||||
// Then we say the event
|
||||
expect(room.getEventReadUpTo(userA)).toEqual("rootId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("valid receipts", () => {
|
||||
beforeEach(() => {
|
||||
// When we look up the event referred to by the receipt, it exists
|
||||
room.findEventById = jest.fn().mockReturnValue({} as MatrixEvent);
|
||||
});
|
||||
|
||||
it("handles missing receipt type", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
return receiptType === ReceiptType.ReadPrivate ? ({ eventId: "eventId" } as WrappedReceipt) : null;
|
||||
};
|
||||
expect(room.getEventReadUpTo(userA)).toEqual("eventId");
|
||||
});
|
||||
|
||||
describe("prefers newer receipt", () => {
|
||||
it("should compare correctly using timelines", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1" } as WrappedReceipt;
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2" } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: (_1, _2) => null,
|
||||
} as EventTimelineSet);
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
compareEventOrdering: (event1: string, _event2: string) => {
|
||||
return event1 === `eventId${i}` ? 1 : -1;
|
||||
},
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
}) as unknown as EventTimelineSet;
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("should correctly compare, if private read receipt is missing", () => {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: (_1, _2) => null,
|
||||
} as EventTimelineSet);
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
|
||||
describe("correctly compares by timestamp", () => {
|
||||
it("should correctly compare, if we have all receipts", () => {
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: () => null,
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
}) as unknown as EventTimelineSet;
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
|
||||
});
|
||||
});
|
||||
it("should correctly compare, if private read receipt is missing", () => {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: () => null,
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
}) as unknown as EventTimelineSet;
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
describe("fallback precedence", () => {
|
||||
beforeAll(() => {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: (_1, _2) => null,
|
||||
} as EventTimelineSet);
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should give precedence to m.read.private", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1", data: { ts: 123 } };
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: 123 } };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
describe("fallback precedence", () => {
|
||||
beforeAll(() => {
|
||||
room.getUnfilteredTimelineSet = () =>
|
||||
({
|
||||
compareEventOrdering: () => null,
|
||||
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
|
||||
}) as unknown as EventTimelineSet;
|
||||
});
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId1`);
|
||||
});
|
||||
it("should give precedence to m.read.private", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.ReadPrivate) {
|
||||
return { eventId: "eventId1", data: { ts: 123 } };
|
||||
}
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId2", data: { ts: 123 } };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
it("should give precedence to m.read", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId3" } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId1`);
|
||||
});
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId3`);
|
||||
it("should give precedence to m.read", () => {
|
||||
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
|
||||
if (receiptType === ReceiptType.Read) {
|
||||
return { eventId: "eventId3" } as WrappedReceipt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
expect(room.getEventReadUpTo(userA)).toEqual(`eventId3`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3564,7 +3710,7 @@ describe("Room", function () {
|
||||
expect(room.polls.get(pollStartEvent.getId()!)).toBeTruthy();
|
||||
|
||||
const redactedEvent = new MatrixEvent({ type: "m.room.redaction" });
|
||||
pollStartEvent.makeRedacted(redactedEvent);
|
||||
pollStartEvent.makeRedacted(redactedEvent, room);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("CrossSigningIdentity", () => {
|
||||
olmMachine = {
|
||||
crossSigningStatus: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
exportCrossSigningKeys: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
@@ -50,6 +51,8 @@ describe("CrossSigningIdentity", () => {
|
||||
|
||||
secretStorage = {
|
||||
get: jest.fn(),
|
||||
hasKey: jest.fn(),
|
||||
store: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
@@ -61,7 +64,8 @@ describe("CrossSigningIdentity", () => {
|
||||
hasSelfSigning: true,
|
||||
hasUserSigning: true,
|
||||
});
|
||||
// TODO: secret storage
|
||||
// in secret storage
|
||||
secretStorage.get.mockResolvedValue("base64-saved-in-storage");
|
||||
await crossSigning.bootstrapCrossSigning({});
|
||||
expect(olmMachine.bootstrapCrossSigning).not.toHaveBeenCalled();
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
@@ -73,6 +77,19 @@ describe("CrossSigningIdentity", () => {
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("Shoud update 4S on reset if 4S is set up", async () => {
|
||||
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
|
||||
secretStorage.hasKey.mockResolvedValue(true);
|
||||
olmMachine.exportCrossSigningKeys.mockResolvedValue({
|
||||
masterKey: "base64_aaaaaaaaaa",
|
||||
self_signing_key: "base64_bbbbbbbbbbb",
|
||||
userSigningKey: "base64_cccccccc",
|
||||
});
|
||||
await crossSigning.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
expect(secretStorage.store).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should call bootstrapCrossSigning if we need new keys", async () => {
|
||||
olmMachine.crossSigningStatus.mockResolvedValue({
|
||||
hasMaster: false,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingReque
|
||||
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
|
||||
import { logger, LogSpan } from "../../../src/logger";
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
@@ -93,10 +94,13 @@ describe("KeyClaimManager", () => {
|
||||
olmMachine.markRequestAsSent.mockResolvedValueOnce(undefined);
|
||||
|
||||
// fire off the request
|
||||
await keyClaimManager.ensureSessionsForUsers([u1, u2]);
|
||||
await keyClaimManager.ensureSessionsForUsers(new LogSpan(logger, "test"), [u1, u2]);
|
||||
|
||||
// check that all the calls were made
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1, u2]);
|
||||
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
|
||||
const calledWith = olmMachine.getMissingSessions.mock.calls[0][0].map((u) => u.toString());
|
||||
expect(calledWith).toEqual([u1.toString(), u2.toString()]);
|
||||
|
||||
expect(fetchMock).toHaveFetched("https://example.com/_matrix/client/v3/keys/claim", {
|
||||
method: "POST",
|
||||
body: { k1: "v1" },
|
||||
@@ -119,12 +123,13 @@ describe("KeyClaimManager", () => {
|
||||
let markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
|
||||
|
||||
// fire off two requests, and keep track of whether their promises resolve
|
||||
const span = new LogSpan(logger, "test");
|
||||
let req1Resolved = false;
|
||||
keyClaimManager.ensureSessionsForUsers([u1]).then(() => {
|
||||
keyClaimManager.ensureSessionsForUsers(span, [u1]).then(() => {
|
||||
req1Resolved = true;
|
||||
});
|
||||
let req2Resolved = false;
|
||||
const req2 = keyClaimManager.ensureSessionsForUsers([u2]).then(() => {
|
||||
const req2 = keyClaimManager.ensureSessionsForUsers(span, [u2]).then(() => {
|
||||
req2Resolved = true;
|
||||
});
|
||||
|
||||
@@ -133,7 +138,10 @@ describe("KeyClaimManager", () => {
|
||||
|
||||
// at this point, there should have been a single call to getMissingSessions, and a single fetch; and neither
|
||||
// call to ensureSessionsAsUsers should have completed
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1]);
|
||||
// check that all the calls were made
|
||||
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
|
||||
const calledWith = olmMachine.getMissingSessions.mock.calls[0][0].map((u) => u.toString());
|
||||
expect(calledWith).toEqual([u1.toString()]);
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(req1Resolved).toBe(false);
|
||||
@@ -145,7 +153,9 @@ describe("KeyClaimManager", () => {
|
||||
resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
|
||||
|
||||
// the first request should now have completed, and we should have more calls and fetches
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u2]);
|
||||
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
|
||||
const calledWith2 = olmMachine.getMissingSessions.mock.calls[1][0].map((u) => u.toString());
|
||||
expect(calledWith2).toEqual([u2.toString()]);
|
||||
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(req1Resolved).toBe(true);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user