Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8d75b81e5 | |||
| 77c118084b | |||
| 097bfe451a | |||
| 6d046edcb2 | |||
| 2abf7ca795 | |||
| 2b46579bd8 | |||
| 6d42ed338e | |||
| ef080c25f9 | |||
| c8d7b458b2 | |||
| f1ba8a8775 | |||
| d21adf568a | |||
| dea184e9ec | |||
| e119bf9040 | |||
| c7f982e190 | |||
| 2e2dd628c1 | |||
| 5ac5a8a799 | |||
| 7d75ab417a | |||
| 3ca81e409a | |||
| 764fdb1d30 | |||
| c2d25d9377 | |||
| c4e1e0723e | |||
| 56b24c0bdc | |||
| aa7a709e3f | |||
| c57c47319e | |||
| 812d0aaef6 | |||
| 61e07633df | |||
| c7dbd6e33b | |||
| 556494b8f0 | |||
| bf3b4e81b2 | |||
| 2710600389 | |||
| ca168c494b | |||
| 759f5ed3eb | |||
| 53deedd2d6 | |||
| 4fbb9f92a7 | |||
| 1a58ce4649 | |||
| 32509d1fd1 | |||
| 11c9e39e5a | |||
| 0ce944f3da | |||
| 38e04c8fb0 | |||
| 1fcbc6ebeb | |||
| aa79236ce2 | |||
| f8f1bf3837 | |||
| 946cf4f359 | |||
| c077201f2a | |||
| 53f2ad41d6 | |||
| be15a709c6 | |||
| 090b8079db | |||
| 06a1e1a88a | |||
| 024b62bba0 | |||
| 6bfb911cf7 | |||
| ad5da7cfa1 | |||
| e55bd1e14c | |||
| 119e859741 | |||
| b7cce93edc | |||
| e5c8c20a34 | |||
| 137379b7b7 | |||
| 4981efa54b | |||
| 6e9740d787 | |||
| 70257e0ab4 | |||
| 9baba151c6 | |||
| b4672e26ec | |||
| 940d358b0e | |||
| 161c12f5d5 | |||
| de659d6431 | |||
| b095aa600d | |||
| 18ea8befdc | |||
| 4f9ca2c697 | |||
| 57a4dc8841 | |||
| 841c02e56d | |||
| 6ece4c3c16 | |||
| 5a3c07f91d | |||
| 3ed4b3ed50 | |||
| 9ca9bd9baf | |||
| 0265f6ea2d | |||
| 8f597f0f87 | |||
| 67df2a53c4 | |||
| 8367277894 | |||
| 4efb27354f | |||
| 28bc90563e | |||
| f9be1bf57a | |||
| adaf921623 | |||
| cdece6cb9f | |||
| d438e25f87 | |||
| 73d8f4384d | |||
| b0cb6aa724 | |||
| dfc26f8aa1 | |||
| 286fb8f752 | |||
| 921de6807d | |||
| d4e7b949e7 | |||
| 40bc833bb7 | |||
| a46ad75440 | |||
| 1e80538cfb | |||
| 73cbcfa4ee | |||
| 99972ce0a9 | |||
| 44399f6017 | |||
| c387f30e5c | |||
| eb9867a5ba | |||
| 12a9875c46 | |||
| 74f5efc4ef | |||
| 43d47982ed | |||
| 94fb489952 | |||
| 75ae05e5eb | |||
| 9058b79c39 | |||
| 671dd2ca40 | |||
| 34f35393ff | |||
| e206a12902 | |||
| 76eabb2efa | |||
| bf6dc16ad3 | |||
| 9398271695 | |||
| ef7a818f70 | |||
| b8903ddf3e | |||
| c35c7d1a3b | |||
| 286e00c500 | |||
| 26922a61f3 | |||
| 457a300c95 | |||
| be04f003ce | |||
| 67b445d40d | |||
| 27f28d5558 | |||
| 52bac9648b | |||
| 7e0e5a3243 | |||
| 337d1791cf | |||
| 622998e949 | |||
| 673806ecaa | |||
| b2232289a6 | |||
| 344c8fad9d | |||
| cccaaf6e56 | |||
| 4dbca983b4 | |||
| 54e8f3c9d0 | |||
| 1fcc375dd5 | |||
| d24c5d8b2b | |||
| bb9280ad6b | |||
| 00bd7f0f02 | |||
| a29b8736f3 | |||
| 1c8a1cd5a1 | |||
| fea619d34c | |||
| f322f32a07 | |||
| bc3abd4394 | |||
| 6721aca293 | |||
| 80b51c0e30 | |||
| 91aa1dc092 | |||
| 78972e81d1 | |||
| 663136a95a | |||
| d67b19fa88 | |||
| 6ec200adcf | |||
| 19b1b901f5 | |||
| 1f52fa0c43 | |||
| b08c083c46 | |||
| 69f1bea89b | |||
| a52c64b747 | |||
| de9eb0e7f2 | |||
| a97d7f1133 | |||
| bc399abf6e | |||
| 1085a2469a | |||
| 52a9c52b12 | |||
| ac49137c0e | |||
| a179227bf1 | |||
| 64e27f5d3c | |||
| 634651859b | |||
| 480c8e86a4 | |||
| 1ba4412260 | |||
| e3a3a52f2a | |||
| 3f03c1da89 | |||
| eb793aaa08 | |||
| f7bab544a7 | |||
| b3fd92ad16 | |||
| ba71235539 | |||
| d6ede767c9 | |||
| 43f388d89a | |||
| 1d46b182c6 | |||
| 352f6986cd | |||
| 0ca72ec466 | |||
| 375f086f44 | |||
| 5976083e32 |
@@ -22,7 +22,7 @@ runs:
|
||||
|
||||
- name: Upload tarball signature
|
||||
if: ${{ inputs.upload-url }}
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ env.VERSION }}.tar.gz.asc
|
||||
|
||||
@@ -29,13 +29,13 @@ runs:
|
||||
|
||||
- name: Upload asset signatures
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}.asc
|
||||
|
||||
- name: Upload assets
|
||||
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # v1
|
||||
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload-url }}
|
||||
asset_path: ${{ inputs.asset-path }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v3
|
||||
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Preview Changelog
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5
|
||||
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
|
||||
if: github.event_name != 'merge_group'
|
||||
with:
|
||||
labels: |
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
script: |
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Add label
|
||||
if: steps.teams.outputs.isTeamMember == 'false'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Close pull request
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check for X-Release-Blocker label on any open issues or PRs
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
REPO: ${{ inputs.repository }}
|
||||
with:
|
||||
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: "yarn"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
disable-autolabeler: true
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Ingest upstream changes
|
||||
if: inputs.include-changes
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
|
||||
@@ -18,14 +18,14 @@ jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/release
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Load GPG key
|
||||
id: gpg
|
||||
if: inputs.gpg-fingerprint
|
||||
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6
|
||||
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
draft: true
|
||||
latest: true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: staging
|
||||
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get actions scripts
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: matrix-org/matrix-js-sdk
|
||||
persist-credentials: false
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Check version number not in use
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const { VERSION } = process.env;
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
git config --global user.email "releases@riot.im"
|
||||
git config --global user.name "RiotRobot"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
|
||||
- name: Validate release has expected assets
|
||||
if: inputs.expected-asset-count
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
git push origin master
|
||||
|
||||
- name: Publish release
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
FINAL: ${{ inputs.final }}
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
id: ${{ steps.npm-publish.outputs.id }}
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -44,13 +44,13 @@ jobs:
|
||||
repo:
|
||||
- element-hq/element-web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: ${{ matrix.repo }}
|
||||
ref: staging
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🧮 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: 🔧 Yarn cache
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
run: yarn gendoc
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: _docs
|
||||
|
||||
@@ -106,4 +106,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
steps:
|
||||
# We create the status here and then update it to success/failure in the `report` stage
|
||||
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
|
||||
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: pending
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
if: ${{ !inputs.sharded }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
name: coverage
|
||||
path: coverage
|
||||
- name: 📥 Download sharded artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
if: inputs.sharded
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v4.0
|
||||
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
|
||||
# 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:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
- uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
if: always()
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
name: "Typescript Syntax Check"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
name: "ESLint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -48,9 +48,9 @@ jobs:
|
||||
name: "Node.js example"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Build Types
|
||||
run: "yarn build:types"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "npm"
|
||||
node-version-file: "examples/node/package.json"
|
||||
@@ -83,9 +83,9 @@ jobs:
|
||||
name: "Workflow Lint"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -100,9 +100,9 @@ jobs:
|
||||
name: "JSDoc Checker"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: docs
|
||||
path: _docs
|
||||
@@ -125,9 +125,9 @@ jobs:
|
||||
name: "Analyse Dead Code"
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version-file: package.json
|
||||
@@ -143,11 +143,11 @@ jobs:
|
||||
if: github.event_name == 'merge_group'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
repository: element-hq/element-web
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: "lts/*"
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
node: ["lts/*", 22]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Setup Node
|
||||
id: setupNode
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
if: env.ENABLE_COVERAGE == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
|
||||
path: |
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
- name: Skip SonarCloud on merge queues
|
||||
if: env.ENABLE_COVERAGE == 'false'
|
||||
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
with:
|
||||
operations-per-run: 250
|
||||
days-before-issue-stale: -1
|
||||
|
||||
+1
-2
@@ -13,8 +13,7 @@ out
|
||||
/dist
|
||||
/lib
|
||||
|
||||
# version file and tarball created by `npm pack` / `yarn pack`
|
||||
/git-revision.txt
|
||||
# tarball created by `npm pack` / `yarn pack`
|
||||
/matrix-js-sdk-*.tgz
|
||||
|
||||
.vscode
|
||||
|
||||
+169
@@ -1,3 +1,172 @@
|
||||
Changes in [38.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.0.0) (2025-08-27)
|
||||
==================================================================================================
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* Release tranche of breaking changes ([#4975](https://github.com/matrix-org/matrix-js-sdk/pull/4975)).
|
||||
* Remove support for FetchHttpApi `onlyData = false`
|
||||
* Remove deprecated `IJoinRoomOpts.syncRoom`
|
||||
* Remove deprecated methods which are unsupported in rust crypto
|
||||
* Remove deprecated getAuthIssuer method
|
||||
* Remove deprecated beginKeyVerification method
|
||||
* Remove deprecated isEncryptedDisabledForUnverifiedDevices getter
|
||||
* Remove deprecated UndecryptableToDeviceEvent MatrixClient emit
|
||||
* Remove deprecated defer utility method
|
||||
* Remove deprecated UIAResponse dummy type
|
||||
* Remove deprecated MatrixRTCSession MembershipConfig fields
|
||||
* Remove deprecated findVerificationRequestDMInProgress and storeSessionBackupPrivateKey methods in favour of overloads
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Allow multiple rtc sessions per room (with different sessionDescriptions) ([#4945](https://github.com/matrix-org/matrix-js-sdk/pull/4945)). Contributed by @toger5.
|
||||
* Add support for login\_hint in authorization url generation ([#4943](https://github.com/matrix-org/matrix-js-sdk/pull/4943)). Contributed by @odelcroi.
|
||||
* Only process MatrixRTC sessions associated with calls for `callMembershipsForRoom` ([#4960](https://github.com/matrix-org/matrix-js-sdk/pull/4960)). Contributed by @fkwp.
|
||||
|
||||
|
||||
Changes in [37.13.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.13.0) (2025-08-11)
|
||||
====================================================================================================
|
||||
This release supports new v12 Matrix rooms and consequently has a breaking change, removing powerLevelNorm from the RoomMember object as this can't be supported with infinite power levels. Apps should use the non-normalised `powerLevel` instead.
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
|
||||
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* [Backport staging] Support v12 rooms in maySendEvent ([#4956](https://github.com/matrix-org/matrix-js-sdk/pull/4956)). Contributed by @RiotRobot.
|
||||
* [Backport staging] Support for creator power level ([#4954](https://github.com/matrix-org/matrix-js-sdk/pull/4954)). Contributed by @RiotRobot.
|
||||
* Experimental support for sharing encrypted history on invite ([#4920](https://github.com/matrix-org/matrix-js-sdk/pull/4920)). Contributed by @richvdh.
|
||||
* Use the logger associated with MatrixClient in rust sdk ([#4918](https://github.com/matrix-org/matrix-js-sdk/pull/4918)). Contributed by @richvdh.
|
||||
* Update to matrix-sdk-crypto-wasm 15.1.0, and add new `ShieldStateCode.MismatchedSender` ([#4916](https://github.com/matrix-org/matrix-js-sdk/pull/4916)). Contributed by @richvdh.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix unknown/broken state in the RTC Membership Manager causing unnecassary error logging. ([#4944](https://github.com/matrix-org/matrix-js-sdk/pull/4944)). Contributed by @toger5.
|
||||
|
||||
|
||||
Changes in [37.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.12.0) (2025-07-29)
|
||||
====================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* Deprecate non-functional `IJoinRoomOpts.syncRoom` ([#4913](https://github.com/matrix-org/matrix-js-sdk/pull/4913)). Contributed by @richvdh.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Custom abort timeout logic for restarting delayed events that is compatible with the widget api ([#4927](https://github.com/matrix-org/matrix-js-sdk/pull/4927)). Contributed by @toger5.
|
||||
* Allow sending notification events when starting a call ([#4826](https://github.com/matrix-org/matrix-js-sdk/pull/4826)). Contributed by @robintown.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix deep import incompatibility (#4924) ([#4925](https://github.com/matrix-org/matrix-js-sdk/pull/4925)). Contributed by @toriningen.
|
||||
* Fix more incorrect logger use ([#4904](https://github.com/matrix-org/matrix-js-sdk/pull/4904)). Contributed by @richvdh.
|
||||
|
||||
|
||||
Changes in [37.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.11.0) (2025-07-15)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update README to make Element sponsorship explicit ([#4901](https://github.com/matrix-org/matrix-js-sdk/pull/4901)). Contributed by @neilisfragile.
|
||||
* Use client logger in more places (crypto code) ([#4900](https://github.com/matrix-org/matrix-js-sdk/pull/4900)). Contributed by @richvdh.
|
||||
* Use client logger in `MatrixRTCSessionManager` ([#4898](https://github.com/matrix-org/matrix-js-sdk/pull/4898)). Contributed by @richvdh.
|
||||
* Use client logger in more places (core code) ([#4899](https://github.com/matrix-org/matrix-js-sdk/pull/4899)). Contributed by @richvdh.
|
||||
* crypto: Add new `ClientEvent.ReceivedToDeviceMessage` with proper `OlmEncryptionInfo` support ([#4891](https://github.com/matrix-org/matrix-js-sdk/pull/4891)). Contributed by @BillCarsonFr.
|
||||
|
||||
|
||||
Changes in [37.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.10.0) (2025-07-01)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Update matrix-sdk-crypto-wasm to `15.0.0` ([#4882](https://github.com/matrix-org/matrix-js-sdk/pull/4882)). Contributed by @richvdh.
|
||||
* Allow customizing the IndexedDB database prefix used by Rust crypto. ([#4878](https://github.com/matrix-org/matrix-js-sdk/pull/4878)). Contributed by @clokep.
|
||||
* Remove `@matrix-org/olm` from dependency list ([#4876](https://github.com/matrix-org/matrix-js-sdk/pull/4876)). Contributed by @richvdh.
|
||||
* Redact on ban: Client implementation ([#4867](https://github.com/matrix-org/matrix-js-sdk/pull/4867)). Contributed by @turt2live.
|
||||
|
||||
|
||||
Changes in [37.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.9.0) (2025-06-17)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Ensure we send spec-compliant filter strings by stripping out null values ([#4865](https://github.com/matrix-org/matrix-js-sdk/pull/4865)). Contributed by @t3chguy.
|
||||
* Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) ([#4861](https://github.com/matrix-org/matrix-js-sdk/pull/4861)). Contributed by @toger5.
|
||||
* Include extraParams in all HTTP requests ([#4860](https://github.com/matrix-org/matrix-js-sdk/pull/4860)). Contributed by @rsb-tbg.
|
||||
|
||||
|
||||
Changes in [37.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.8.0) (2025-06-10)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 ([#4869](https://github.com/matrix-org/matrix-js-sdk/pull/4869)). Contributed by @RiotRobot.
|
||||
|
||||
|
||||
Changes in [37.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.7.0) (2025-06-03)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* MatrixRTC: Rename `MembershipConfig` parameters ([#4714](https://github.com/matrix-org/matrix-js-sdk/pull/4714)). Contributed by @toger5.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Allow the embedded client to work without update\_state support ([#4849](https://github.com/matrix-org/matrix-js-sdk/pull/4849)). Contributed by @robintown.
|
||||
* Check for `unknown variant` on to-device sending and fall back to room event encryption. ([#4847](https://github.com/matrix-org/matrix-js-sdk/pull/4847)). Contributed by @toger5.
|
||||
* Reapply "Distinguish room state and timeline events in embedded clients" ([#4790](https://github.com/matrix-org/matrix-js-sdk/pull/4790)). Contributed by @robintown.
|
||||
|
||||
|
||||
Changes in [37.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.6.0) (2025-05-20)
|
||||
==================================================================================================
|
||||
## 🦖 Deprecations
|
||||
|
||||
* Deprecate utils function `defer` in favour of `Promise.withResolvers` ([#4829](https://github.com/matrix-org/matrix-js-sdk/pull/4829)). Contributed by @t3chguy.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Update to Node 22 LTS ([#4832](https://github.com/matrix-org/matrix-js-sdk/pull/4832)). Contributed by @t3chguy.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix autodiscovery handling of 2xx (non-200) codes ([#4833](https://github.com/matrix-org/matrix-js-sdk/pull/4833)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.5.0) (2025-05-06)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Stabilise MSC3765 ([#4767](https://github.com/matrix-org/matrix-js-sdk/pull/4767)). Contributed by @Johennes.
|
||||
* Inherit `methodFactory` extensions from the parent to the child loggers. ([#4809](https://github.com/matrix-org/matrix-js-sdk/pull/4809)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* [Backport staging] Fix token refresh behaviour for non-expired tokens ([#4827](https://github.com/matrix-org/matrix-js-sdk/pull/4827)). Contributed by @RiotRobot.
|
||||
* Refactor how token refreshing works to be more resilient ([#4819](https://github.com/matrix-org/matrix-js-sdk/pull/4819)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [37.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.4.0) (2025-04-22)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC: Add combined `toDeviceAndRoomKeyTransport` ([#4792](https://github.com/matrix-org/matrix-js-sdk/pull/4792)). Contributed by @toger5.
|
||||
* Make logging consistent for matrixRTC ([#4788](https://github.com/matrix-org/matrix-js-sdk/pull/4788)). Contributed by @toger5.
|
||||
* MatrixRTC: ToDevice distribution for media stream keys ([#4785](https://github.com/matrix-org/matrix-js-sdk/pull/4785)). Contributed by @BillCarsonFr.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix token refresh racing with other requests and not using new token ([#4798](https://github.com/matrix-org/matrix-js-sdk/pull/4798)). Contributed by @t3chguy.
|
||||
* Fix fallback to MemoryCryptoStore when LocalStorage unavailable ([#4797](https://github.com/matrix-org/matrix-js-sdk/pull/4797)). Contributed by @t3chguy.
|
||||
* Remove duplicate `deleteSecretStorage` in `RustCrypto.resetEncryption` ([#4789](https://github.com/matrix-org/matrix-js-sdk/pull/4789)). Contributed by @florianduros.
|
||||
* Fix `RustCrypto.resetEncryption` failure ([#4772](https://github.com/matrix-org/matrix-js-sdk/pull/4772)). Contributed by @florianduros.
|
||||
|
||||
|
||||
Changes in [37.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.3.0) (2025-04-08)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* MatrixRTC MembershipManger: remove redundant sendDelayedEventAction and expose status ([#4747](https://github.com/matrix-org/matrix-js-sdk/pull/4747)). Contributed by @toger5.
|
||||
* Abstract logout-causing error type from tokenRefreshFunction calls ([#4765](https://github.com/matrix-org/matrix-js-sdk/pull/4765)). Contributed by @t3chguy.
|
||||
* Improve PushProcessor::getPushRuleGlobRegex ([#4764](https://github.com/matrix-org/matrix-js-sdk/pull/4764)). Contributed by @t3chguy.
|
||||
* Export push processor \& method for converting matrix glob to regexp ([#4763](https://github.com/matrix-org/matrix-js-sdk/pull/4763)). Contributed by @t3chguy.
|
||||
* Add authenticated media parameter to getMediaConfig ([#4762](https://github.com/matrix-org/matrix-js-sdk/pull/4762)). Contributed by @m004.
|
||||
* Rust crypto: set a timeout on outgoing HTTP requests ([#4761](https://github.com/matrix-org/matrix-js-sdk/pull/4761)). Contributed by @richvdh.
|
||||
* Switch sliding sync support to simplified sliding sync ([#4400](https://github.com/matrix-org/matrix-js-sdk/pull/4400)). Contributed by @dbkr.
|
||||
|
||||
|
||||
Changes in [37.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.2.0) (2025-03-25)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
|
||||
browser or in Node.js.
|
||||
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source srcset="contrib/element-logo-light.png" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="contrib/element-logo-dark.png" media="(prefers-color-scheme: light)">
|
||||
<img src="contrib/element-logo-fallback.png" alt="Element logo">
|
||||
</picture>
|
||||
|
||||
<br>
|
||||
|
||||
Development and maintenance is proudly sponsored by [Element](https://element.io). Element uses the SDK in their flagship [web](https://github.com/element-hq/element-web) and [desktop](https://github.com/element-hq/element-desktop) clients.
|
||||
|
||||
The SDK is also the basis for multiple Matrix projects and we welcome contributions from all.
|
||||
|
||||
---
|
||||
|
||||
#### Minimum Matrix server version: v1.1
|
||||
|
||||
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
|
||||
|
||||
@@ -119,7 +119,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
|
||||
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
|
||||
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
|
||||
|
||||
1. Note that an explicit type is optional if not expected to be used outside of the function call,
|
||||
unlike in this example:
|
||||
|
||||
@@ -153,7 +152,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
28. Export only what can be reused.
|
||||
29. Prefer a type like `Optional<X>` (`type Optional<T> = T | null | undefined`) instead
|
||||
of truly optional parameters.
|
||||
|
||||
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
|
||||
takes an argument that is more often not required than required. An example where the
|
||||
`?` operator is inappropriate is when taking a room ID: typically the caller should
|
||||
@@ -272,7 +270,6 @@ Unless otherwise specified, the following applies to all code:
|
||||
interfaces have a habit of becoming more complex over time.
|
||||
|
||||
3. Inside a function, there is no need to comment every line, but consider:
|
||||
|
||||
- before a particular multiline section of code within the function, give an overview of what it does,
|
||||
to make it easier for a reader to follow the flow through the function as a whole.
|
||||
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -9,6 +9,7 @@ export default {
|
||||
"src/crypto-api/index.ts",
|
||||
"src/testing.ts",
|
||||
"src/matrix.ts",
|
||||
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
|
||||
"scripts/**",
|
||||
"spec/**",
|
||||
// XXX: these look entirely unused
|
||||
|
||||
+11
-13
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "37.3.0-rc.0",
|
||||
"version": "38.0.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn build",
|
||||
"prepare": "yarn build",
|
||||
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
|
||||
"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": "yarn clean && yarn build:compile && yarn build:types",
|
||||
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"gendoc": "typedoc",
|
||||
@@ -50,13 +49,12 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.7.1",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
@@ -81,14 +79,14 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@casualbot/jest-sonar-reporter": "2.2.7",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@peculiar/webcrypto": "^1.4.5",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "18",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
@@ -114,14 +112,14 @@
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"knip": "^5.0.0",
|
||||
"lint-staged": "^15.0.2",
|
||||
"lint-staged": "^16.0.0",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier": "3.6.2",
|
||||
"rimraf": "^6.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.28.1",
|
||||
"typedoc-plugin-coverage": "^3.0.0",
|
||||
"typedoc-plugin-coverage": "^4.0.0",
|
||||
"typedoc-plugin-mdn-links": "^5.0.0",
|
||||
"typedoc-plugin-missing-exports": "^4.0.0",
|
||||
"typescript": "^5.4.2"
|
||||
|
||||
@@ -19,9 +19,6 @@ limitations under the License.
|
||||
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
|
||||
/* eslint-disable jest/no-standalone-expect */
|
||||
|
||||
// load olm before the sdk if possible
|
||||
import "./olm-loader";
|
||||
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
|
||||
|
||||
@@ -17,9 +17,10 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import debug from "debug";
|
||||
|
||||
import { syncPromise } from "../../test-utils/test-utils";
|
||||
import { type AuthDict, createClient, type MatrixClient } from "../../../src";
|
||||
import { type AuthDict, createClient, DebugLogger, type MatrixClient } from "../../../src";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
|
||||
import encryptAESSecretStorageItem from "../../../src/utils/encryptAESSecretStorageItem.ts";
|
||||
import { type CryptoCallbacks, CrossSigningKey } from "../../../src/crypto-api";
|
||||
@@ -91,6 +92,7 @@ describe("cross-signing", () => {
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
cryptoCallbacks: createCryptoCallbacks(),
|
||||
logger: new DebugLogger(debug(`matrix-js-sdk:cross-signing`)),
|
||||
});
|
||||
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
@@ -135,9 +137,9 @@ describe("cross-signing", () => {
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
// check that the cross-signing keys have been uploaded
|
||||
expect(fetchMock.called("upload-cross-signing-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-cross-signing-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
@@ -223,9 +225,6 @@ describe("cross-signing", () => {
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
|
||||
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
|
||||
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
||||
@@ -418,15 +417,18 @@ describe("cross-signing", () => {
|
||||
return new Promise<any>((resolve) => {
|
||||
fetchMock.post(
|
||||
{
|
||||
url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
url: new URL(
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
aliceClient.getHomeserverUrl(),
|
||||
).toString(),
|
||||
name: "upload-cross-signing-keys",
|
||||
},
|
||||
(url, options) => {
|
||||
const content = JSON.parse(options.body as string);
|
||||
resolve(content);
|
||||
return {};
|
||||
},
|
||||
// Override the routes define in `mockSetupCrossSigningRequests`
|
||||
// Override the route defined in E2EKeyReceiver
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,9 +19,9 @@ import anotherjson from "another-json";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import type FetchMock from "fetch-mock";
|
||||
import type Olm from "@matrix-org/olm";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import {
|
||||
emitPromise,
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
} from "../../../src/matrix";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import {
|
||||
@@ -124,7 +124,6 @@ async function expectSendRoomKey(
|
||||
recipientOlmAccount: Olm.Account,
|
||||
recipientOlmSession: Olm.Session | null = null,
|
||||
): Promise<Olm.InboundGroupSession> {
|
||||
const Olm = globalThis.Olm;
|
||||
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
|
||||
|
||||
function onSendRoomKey(content: any): Olm.InboundGroupSession {
|
||||
@@ -207,14 +206,6 @@ async function expectSendMegolmMessage(
|
||||
}
|
||||
|
||||
describe("crypto", () => {
|
||||
if (!globalThis.Olm) {
|
||||
// currently we use libolm to implement the crypto in the tests, so need it to be present.
|
||||
logger.warn("not running megolm tests: Olm not present");
|
||||
return;
|
||||
}
|
||||
|
||||
const Olm = globalThis.Olm;
|
||||
|
||||
let testOlmAccount = {} as unknown as Olm.Account;
|
||||
let testSenderKey = "";
|
||||
|
||||
@@ -1283,13 +1274,13 @@ describe("crypto", () => {
|
||||
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
|
||||
|
||||
// ... and finally, send the room key. We block the response until `sendRoomMessageDefer` completes.
|
||||
const sendRoomMessageDefer = defer<FetchMock.MockResponse>();
|
||||
const sendRoomMessageResolvers = Promise.withResolvers<FetchMock.MockResponse>();
|
||||
const reqProm = new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp("/send/m.room.encrypted/"),
|
||||
async (url: string, opts: RequestInit): Promise<FetchMock.MockResponse> => {
|
||||
resolve(JSON.parse(opts.body as string));
|
||||
return await sendRoomMessageDefer.promise;
|
||||
return await sendRoomMessageResolvers.promise;
|
||||
},
|
||||
{
|
||||
// append to the list of intercepts on this path (since we have some tests that call
|
||||
@@ -1318,7 +1309,7 @@ describe("crypto", () => {
|
||||
|
||||
// release the send request
|
||||
const resp = { event_id: "$event_id" };
|
||||
sendRoomMessageDefer.resolve(resp);
|
||||
sendRoomMessageResolvers.resolve(resp);
|
||||
expect(await sendProm).toEqual(resp);
|
||||
|
||||
// still pending at this point
|
||||
@@ -1500,8 +1491,10 @@ describe("crypto", () => {
|
||||
|
||||
expect(ev.decryptionFailureReason).toEqual(expectedErrorCode);
|
||||
|
||||
// `isEncryptedDisabledForUnverifiedDevices` should be true for `m.unverified` and false for other errors.
|
||||
expect(ev.isEncryptedDisabledForUnverifiedDevices).toEqual(withheldCode === "m.unverified");
|
||||
// `decryptionFailureReason` should be `MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE` for `m.unverified`
|
||||
expect(
|
||||
ev.decryptionFailureReason === DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
|
||||
).toEqual(withheldCode === "m.unverified");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,8 +16,9 @@ limitations under the License.
|
||||
|
||||
import "fake-indexeddb/auto";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import debug from "debug";
|
||||
|
||||
import { ClientEvent, createClient, type MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { ClientEvent, createClient, DebugLogger, type MatrixClient, MatrixEvent } from "../../../src";
|
||||
import { CryptoEvent } from "../../../src/crypto-api/index";
|
||||
import { type RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { type AddSecretStorageKeyOpts } from "../../../src/secret-storage";
|
||||
@@ -38,6 +39,7 @@ describe("Device dehydration", () => {
|
||||
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
|
||||
},
|
||||
},
|
||||
logger: new DebugLogger(debug(`matrix-js-sdk:dehydration`)),
|
||||
});
|
||||
|
||||
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
|
||||
@@ -179,8 +181,6 @@ async function initializeSecretStorage(
|
||||
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
|
||||
const accountData: Map<string, object> = new Map();
|
||||
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
Copyright 2025 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 fetchMock from "fetch-mock-jest";
|
||||
import mkDebug from "debug";
|
||||
|
||||
import {
|
||||
createClient,
|
||||
DebugLogger,
|
||||
EventType,
|
||||
type IContent,
|
||||
KnownMembership,
|
||||
type MatrixClient,
|
||||
MsgType,
|
||||
} from "../../../src";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder.ts";
|
||||
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts";
|
||||
import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
|
||||
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
||||
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
|
||||
import { escapeRegExp } from "../../../src/utils.ts";
|
||||
|
||||
const debug = mkDebug("matrix-js-sdk:history-sharing");
|
||||
|
||||
// load the rust library. This can take a few seconds on a slow GH worker.
|
||||
beforeAll(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-wasm");
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
}, 10000);
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const ROOM_ID = "!room:example.com";
|
||||
const ALICE_HOMESERVER_URL = "https://alice-server.com";
|
||||
const BOB_HOMESERVER_URL = "https://bob-server.com";
|
||||
|
||||
async function createAndInitClient(homeserverUrl: string, userId: string) {
|
||||
mockInitialApiRequests(homeserverUrl, userId);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: userId,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "xzcvb",
|
||||
logger: new DebugLogger(mkDebug(`matrix-js-sdk:${userId}`)),
|
||||
});
|
||||
|
||||
await client.initRustCrypto({ cryptoDatabasePrefix: userId });
|
||||
await client.startClient();
|
||||
await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("History Sharing", () => {
|
||||
let aliceClient: MatrixClient;
|
||||
let aliceSyncResponder: SyncResponder;
|
||||
let bobClient: MatrixClient;
|
||||
let bobSyncResponder: SyncResponder;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const aliceId = "@alice:localhost";
|
||||
const bobId = "@bob:xyz";
|
||||
|
||||
const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-");
|
||||
const aliceKeyResponder = new E2EKeyResponder(ALICE_HOMESERVER_URL);
|
||||
const aliceKeyClaimResponder = new E2EOTKClaimResponder(ALICE_HOMESERVER_URL);
|
||||
aliceSyncResponder = new SyncResponder(ALICE_HOMESERVER_URL);
|
||||
|
||||
const bobKeyReceiver = new E2EKeyReceiver(BOB_HOMESERVER_URL, "bob-");
|
||||
const bobKeyResponder = new E2EKeyResponder(BOB_HOMESERVER_URL);
|
||||
bobSyncResponder = new SyncResponder(BOB_HOMESERVER_URL);
|
||||
|
||||
aliceKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
|
||||
aliceKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
|
||||
bobKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
|
||||
bobKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
|
||||
|
||||
aliceClient = await createAndInitClient(ALICE_HOMESERVER_URL, aliceId);
|
||||
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobId);
|
||||
|
||||
aliceKeyClaimResponder.addKeyReceiver(bobId, bobClient.deviceId!, bobKeyReceiver);
|
||||
|
||||
aliceSyncResponder.sendOrQueueSyncResponse({});
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
bobSyncResponder.sendOrQueueSyncResponse({});
|
||||
await syncPromise(bobClient);
|
||||
});
|
||||
|
||||
test("Room keys are successfully shared on invite", async () => {
|
||||
// Alice is in an encrypted room
|
||||
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
|
||||
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
// ... and she sends an event
|
||||
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
|
||||
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hi!" });
|
||||
const sentMessage = await msgProm;
|
||||
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
|
||||
|
||||
// Now, Alice invites Bob
|
||||
const uploadProm = new Promise<Uint8Array>((resolve) => {
|
||||
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => {
|
||||
const body = request.body as Uint8Array;
|
||||
debug(`Alice uploaded blob of length ${body.length}`);
|
||||
resolve(body);
|
||||
return { content_uri: "mxc://alice-server/here" };
|
||||
});
|
||||
});
|
||||
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
|
||||
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
|
||||
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
|
||||
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
|
||||
const uploadedBlob = await uploadProm;
|
||||
const sentToDeviceRequest = await toDeviceMessageProm;
|
||||
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
|
||||
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
|
||||
expect(bobToDeviceMessage).toBeDefined();
|
||||
|
||||
// Bob receives the to-device event and the room invite
|
||||
const inviteEvent = mkEventCustom({
|
||||
type: "m.room.member",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
state_key: bobClient.getSafeUserId(),
|
||||
content: { membership: KnownMembership.Invite },
|
||||
});
|
||||
bobSyncResponder.sendOrQueueSyncResponse({
|
||||
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
|
||||
to_device: {
|
||||
events: [
|
||||
{
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
content: bobToDeviceMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await syncPromise(bobClient);
|
||||
|
||||
const room = bobClient.getRoom(ROOM_ID);
|
||||
expect(room).toBeTruthy();
|
||||
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
|
||||
|
||||
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
|
||||
room_id: ROOM_ID,
|
||||
});
|
||||
fetchMock.getOnce(
|
||||
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
|
||||
{ body: uploadedBlob },
|
||||
{ sendAsJson: false },
|
||||
);
|
||||
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
|
||||
|
||||
// Bob receives, should be able to decrypt, the megolm message
|
||||
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
|
||||
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
|
||||
mkEventCustom({
|
||||
type: "m.room.encrypted",
|
||||
sender: aliceClient.getSafeUserId(),
|
||||
content: sentMessage,
|
||||
event_id: "$event_id",
|
||||
}) as any,
|
||||
);
|
||||
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
|
||||
await syncPromise(bobClient);
|
||||
|
||||
const bobRoom = bobClient.getRoom(ROOM_ID);
|
||||
const event = bobRoom!.getLastLiveEvent()!;
|
||||
expect(event.getId()).toEqual("$event_id");
|
||||
await event.getDecryptionPromise();
|
||||
expect(event.getType()).toEqual("m.room.message");
|
||||
expect(event.getContent().body).toEqual("Hi!");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
bobClient.stopClient();
|
||||
aliceClient.stopClient();
|
||||
await flushPromises();
|
||||
});
|
||||
});
|
||||
|
||||
function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise<IContent> {
|
||||
return new Promise<IContent>((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/rooms/[^/]*/send/${escapeRegExp(msgtype)}/`),
|
||||
(url, request) => {
|
||||
const content = JSON.parse(request.body as string);
|
||||
resolve(content);
|
||||
return { event_id: "$event_id" };
|
||||
},
|
||||
{ name: "sendRoomEvent" },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function expectSendToDeviceMessage(
|
||||
homeserverUrl: string,
|
||||
msgtype: string,
|
||||
): Promise<Record<string, Record<string, object>>> {
|
||||
return new Promise((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/sendToDevice/${escapeRegExp(msgtype)}/`),
|
||||
(url: string, opts: RequestInit) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
resolve(body.messages);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
|
||||
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
|
||||
|
||||
@@ -861,7 +860,7 @@ describe("megolm-keys backup", () => {
|
||||
expect(await aliceCrypto.getKeyBackupInfo()).toStrictEqual(testData.SIGNED_BACKUP_DATA);
|
||||
|
||||
// Delete the backup and we are expecting the key backup to be disabled
|
||||
const keyBackupStatus = defer<boolean>();
|
||||
const keyBackupStatus = Promise.withResolvers<boolean>();
|
||||
aliceClient.once(CryptoEvent.KeyBackupStatus, (enabled) => keyBackupStatus.resolve(enabled));
|
||||
await aliceCrypto.deleteKeyBackupVersion(testData.SIGNED_BACKUP_DATA.version!);
|
||||
expect(await keyBackupStatus.promise).toBe(false);
|
||||
@@ -1158,7 +1157,7 @@ describe("megolm-keys backup", () => {
|
||||
// A check backup should happen at some point
|
||||
await aliceCrypto.checkKeyBackupAndEnable();
|
||||
|
||||
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
|
||||
const awaitHasQueriedNewBackup: PromiseWithResolvers<void> = Promise.withResolvers<void>();
|
||||
|
||||
fetchMock.get(
|
||||
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
|
||||
|
||||
@@ -93,15 +93,15 @@ export function bootstrapCrossSigningTestOlmAccount(
|
||||
deviceId: string,
|
||||
keyBackupInfo: KeyBackupInfo[] = [],
|
||||
): Partial<IDownloadKeyResult> {
|
||||
const olmAliceMSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceMSK = new Olm.PkSigning();
|
||||
const masterPrivkey = olmAliceMSK.generate_seed();
|
||||
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
|
||||
|
||||
const olmAliceUSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceUSK = new Olm.PkSigning();
|
||||
const userPrivkey = olmAliceUSK.generate_seed();
|
||||
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
|
||||
|
||||
const olmAliceSSK = new globalThis.Olm.PkSigning();
|
||||
const olmAliceSSK = new Olm.PkSigning();
|
||||
const sskPrivkey = olmAliceSSK.generate_seed();
|
||||
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function createOlmSession(
|
||||
const otkId = Object.keys(keys)[0];
|
||||
const otk = keys[otkId];
|
||||
|
||||
const session = new globalThis.Olm.Session();
|
||||
const session = new Olm.Session();
|
||||
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,23 @@ describe("MatrixClient.initRustCrypto", () => {
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the indexed db with a custom prefix", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
|
||||
|
||||
// should have an indexed db now
|
||||
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
|
||||
expect(databaseNames).toEqual(expect.arrayContaining(["my-prefix::matrix-sdk-crypto"]));
|
||||
});
|
||||
|
||||
it("should create the meta db if given a storageKey", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
@@ -475,4 +492,22 @@ describe("MatrixClient.clearStores", () => {
|
||||
await matrixClient.clearStores();
|
||||
// No error thrown in clearStores
|
||||
});
|
||||
|
||||
it("should clear the indexeddbs with a custom prefix", async () => {
|
||||
const matrixClient = createClient({
|
||||
baseUrl: "http://test.server",
|
||||
userId: "@alice:localhost",
|
||||
deviceId: "aliceDevice",
|
||||
});
|
||||
|
||||
// No databases.
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
|
||||
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
|
||||
expect(await indexedDB.databases()).toHaveLength(1);
|
||||
await matrixClient.stopClient();
|
||||
|
||||
await matrixClient.clearStores({ cryptoDatabasePrefix: "my-prefix" });
|
||||
expect(await indexedDB.databases()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,22 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import Olm from "@matrix-org/olm";
|
||||
|
||||
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { createClient, type MatrixClient } from "../../../src";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type IToDeviceEvent,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "../../../src";
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys } from "./olm-utils.ts";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -43,6 +52,8 @@ describe("to-device-messages", () => {
|
||||
|
||||
/** an object which intercepts `/keys/query` requests on the test homeserver */
|
||||
let e2eKeyResponder: E2EKeyResponder;
|
||||
let e2eKeyReceiver: E2EKeyReceiver;
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
beforeEach(
|
||||
async () => {
|
||||
@@ -59,8 +70,8 @@ describe("to-device-messages", () => {
|
||||
});
|
||||
|
||||
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||
new E2EKeyReceiver(homeserverUrl);
|
||||
const syncResponder = new SyncResponder(homeserverUrl);
|
||||
e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||
syncResponder = new SyncResponder(homeserverUrl);
|
||||
|
||||
// add bob as known user
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
|
||||
@@ -149,4 +160,111 @@ describe("to-device-messages", () => {
|
||||
// for future: check that bob's device can decrypt the ciphertext?
|
||||
});
|
||||
});
|
||||
|
||||
describe("receive to-device-messages", () => {
|
||||
it("Should receive decrypted to-device message via ClientEvent", async () => {
|
||||
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
|
||||
await Olm.init();
|
||||
const testOlmAccount = new Olm.Account();
|
||||
testOlmAccount.create();
|
||||
|
||||
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
|
||||
e2eKeyResponder.addDeviceKeys(testDeviceKeys);
|
||||
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const p2pSession = await establishOlmSession(aliceClient, e2eKeyReceiver, syncResponder, testOlmAccount);
|
||||
|
||||
const toDeviceEvent = encryptOlmEvent({
|
||||
sender: "@bob:xyz",
|
||||
senderKey: testDeviceKeys.keys[`curve25519:DEVICE_ID`],
|
||||
senderSigningKey: testDeviceKeys.keys[`ed25519:DEVICE_ID`],
|
||||
p2pSession: p2pSession,
|
||||
recipient: aliceClient.getUserId()!,
|
||||
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
|
||||
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
|
||||
plaincontent: {
|
||||
body: "foo",
|
||||
},
|
||||
plaintype: "m.test.type",
|
||||
});
|
||||
|
||||
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
|
||||
processedToDeviceResolver.resolve(payload);
|
||||
});
|
||||
|
||||
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
|
||||
oldToDeviceResolver.resolve(event);
|
||||
});
|
||||
|
||||
expect(toDeviceEvent.type).toBe("m.room.encrypted");
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
|
||||
|
||||
expect(message.type).toBe("m.test.type");
|
||||
expect(message.content["body"]).toBe("foo");
|
||||
|
||||
expect(encryptionInfo).not.toBeNull();
|
||||
expect(encryptionInfo!.senderVerified).toBe(false);
|
||||
expect(encryptionInfo!.sender).toBe("@bob:xyz");
|
||||
expect(encryptionInfo!.senderDevice).toBe("DEVICE_ID");
|
||||
|
||||
const oldFormat = await oldToDeviceResolver.promise;
|
||||
expect(oldFormat.isEncrypted()).toBe(true);
|
||||
expect(oldFormat.getType()).toBe("m.test.type");
|
||||
expect(oldFormat.getContent()["body"]).toBe("foo");
|
||||
});
|
||||
|
||||
it("Should receive clear to-device message via ClientEvent", async () => {
|
||||
await aliceClient.startClient();
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const toDeviceEvent: IToDeviceEvent = {
|
||||
sender: "@bob:xyz",
|
||||
type: "m.test.type",
|
||||
content: {
|
||||
body: "foo",
|
||||
},
|
||||
};
|
||||
|
||||
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
|
||||
processedToDeviceResolver.resolve(payload);
|
||||
});
|
||||
|
||||
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
|
||||
|
||||
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
|
||||
oldToDeviceResolver.resolve(event);
|
||||
});
|
||||
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
|
||||
await syncPromise(aliceClient);
|
||||
|
||||
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
|
||||
|
||||
expect(message.type).toBe("m.test.type");
|
||||
expect(message.content["body"]).toBe("foo");
|
||||
|
||||
// When the message is not encrypted, we don't have the encryptionInfo.
|
||||
expect(encryptionInfo).toBeNull();
|
||||
|
||||
const oldFormat = await oldToDeviceResolver.promise;
|
||||
expect(oldFormat.isEncrypted()).toBe(false);
|
||||
expect(oldFormat.getType()).toBe("m.test.type");
|
||||
expect(oldFormat.getContent()["body"]).toBe("foo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import anotherjson from "another-json";
|
||||
import debug from "debug";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { createHash } from "crypto";
|
||||
@@ -25,6 +26,7 @@ import Olm from "@matrix-org/olm";
|
||||
import type FetchMock from "fetch-mock";
|
||||
import {
|
||||
createClient,
|
||||
DebugLogger,
|
||||
DeviceVerification,
|
||||
type IContent,
|
||||
type ICreateClientOpts,
|
||||
@@ -44,7 +46,7 @@ import {
|
||||
type Verifier,
|
||||
VerifierEvent,
|
||||
} from "../../../src/crypto-api/verification";
|
||||
import { defer, escapeRegExp } from "../../../src/utils";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
@@ -84,7 +86,7 @@ jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
|
||||
|
||||
beforeAll(async () => {
|
||||
// we use the libolm primitives in the test, so init the Olm library
|
||||
await globalThis.Olm.init();
|
||||
await Olm.init();
|
||||
});
|
||||
|
||||
// load the rust library. This can take a few seconds on a slow GH worker.
|
||||
@@ -110,7 +112,6 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
// we test with both crypto stacks...
|
||||
describe("verification", () => {
|
||||
/** the client under test */
|
||||
let aliceClient: MatrixClient;
|
||||
@@ -254,7 +255,7 @@ describe("verification", () => {
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new globalThis.Olm.SAS();
|
||||
const olmSAS = new Olm.SAS();
|
||||
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
@@ -348,7 +349,7 @@ describe("verification", () => {
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new globalThis.Olm.SAS();
|
||||
const olmSAS = new Olm.SAS();
|
||||
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);
|
||||
|
||||
sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
|
||||
@@ -1476,6 +1477,7 @@ describe("verification", () => {
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "device_under_test",
|
||||
logger: new DebugLogger(debug(`matrix-js-sdk:verification`)),
|
||||
...opts,
|
||||
});
|
||||
await client.initRustCrypto();
|
||||
@@ -1540,10 +1542,10 @@ function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }>
|
||||
* @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>();
|
||||
const mskRequestResolvers = Promise.withResolvers<string>();
|
||||
const sskRequestResolvers = Promise.withResolvers<string>();
|
||||
const uskRequestResolvers = Promise.withResolvers<string>();
|
||||
const backupKeyRequestResolvers = Promise.withResolvers<string>();
|
||||
|
||||
fetchMock.put(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/m.secret.request`),
|
||||
@@ -1555,13 +1557,13 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
const name = content.name;
|
||||
const requestId = content.request_id;
|
||||
if (name == "m.cross_signing.user_signing") {
|
||||
uskRequestDefer.resolve(requestId);
|
||||
uskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.master") {
|
||||
mskRequestDefer.resolve(requestId);
|
||||
mskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.cross_signing.self_signing") {
|
||||
sskRequestDefer.resolve(requestId);
|
||||
sskRequestResolvers.resolve(requestId);
|
||||
} else if (name == "m.megolm_backup.v1") {
|
||||
backupKeyRequestDefer.resolve(requestId);
|
||||
backupKeyRequestResolvers.resolve(requestId);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
@@ -1570,10 +1572,10 @@ function mockSecretRequestAndGetPromises(): Map<string, Promise<string>> {
|
||||
);
|
||||
|
||||
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);
|
||||
promiseMap.set("m.cross_signing.master", mskRequestResolvers.promise);
|
||||
promiseMap.set("m.cross_signing.self_signing", sskRequestResolvers.promise);
|
||||
promiseMap.set("m.cross_signing.user_signing", uskRequestResolvers.promise);
|
||||
promiseMap.set("m.megolm_backup.v1", backupKeyRequestResolvers.promise);
|
||||
return promiseMap;
|
||||
}
|
||||
|
||||
@@ -1604,7 +1606,7 @@ function sha256(commitmentStr: string): string {
|
||||
return encodeUnpaddedBase64(createHash("sha256").update(commitmentStr, "utf8").digest());
|
||||
}
|
||||
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayLike<number>): string {
|
||||
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
@@ -1638,7 +1640,7 @@ function buildReadyMessage(
|
||||
}
|
||||
|
||||
/** build an m.key.verification.start to-device message suitable for the m.reciprocate.v1 flow, originating from the dummy device */
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayBuffer) {
|
||||
function buildReciprocateStartMessage(transactionId: string, sharedSecret: ArrayLike<number>) {
|
||||
return {
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
|
||||
@@ -267,6 +267,59 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invite", function () {
|
||||
it("should send request to /invite", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const userId = "@user:server";
|
||||
|
||||
httpBackend
|
||||
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ user_id: userId });
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client.invite(roomId, userId);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
});
|
||||
|
||||
it("accepts a stringy reason argument", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const userId = "@user:server";
|
||||
|
||||
httpBackend
|
||||
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ user_id: userId, reason: "testreason" });
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client.invite(roomId, userId, "testreason");
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
});
|
||||
|
||||
it("accepts an options object with a reason", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const userId = "@user:server";
|
||||
|
||||
httpBackend
|
||||
.when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`)
|
||||
.check((request) => {
|
||||
expect(request.data).toEqual({ user_id: userId, reason: "testreason" });
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
const prom = client.invite(roomId, userId, { reason: "testreason" });
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
});
|
||||
});
|
||||
|
||||
describe("knockRoom", function () {
|
||||
const roomId = "!some-room-id:example.org";
|
||||
const reason = "some reason";
|
||||
@@ -1796,6 +1849,27 @@ describe("MatrixClient", function () {
|
||||
expect(client.getUserIdLocalpart()).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRoomMutePushRule", () => {
|
||||
it("should set room push rule to muted", async () => {
|
||||
const roomId = "!roomId:server";
|
||||
const client = new MatrixClient({
|
||||
baseUrl: "http://localhost",
|
||||
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
|
||||
});
|
||||
client.pushRules = {
|
||||
global: {
|
||||
room: [{ rule_id: roomId, actions: [], default: false, enabled: false }],
|
||||
},
|
||||
};
|
||||
|
||||
const path = `/pushrules/global/room/${encodeURIComponent(roomId)}`;
|
||||
httpBackend.when("DELETE", path).respond(200, {});
|
||||
httpBackend.when("PUT", path).respond(200, {});
|
||||
client.setRoomMutePushRule("global", roomId, true);
|
||||
await httpBackend.flush("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
|
||||
|
||||
@@ -205,4 +205,109 @@ describe("MatrixClient opts", function () {
|
||||
expect(res.event_id).toEqual("foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with opts.queryParams", function () {
|
||||
let client: MatrixClient;
|
||||
let httpBackend: HttpBackend;
|
||||
const userId = "@rsb-tbg:localhost";
|
||||
|
||||
beforeEach(function () {
|
||||
httpBackend = new HttpBackend();
|
||||
client = new MatrixClient({
|
||||
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
|
||||
store: new MemoryStore() as IStore,
|
||||
baseUrl: baseUrl,
|
||||
userId: userId,
|
||||
accessToken: accessToken,
|
||||
queryParams: { user_id: userId },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
client.stopClient();
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
it("should include queryParams in matrix server requests", async () => {
|
||||
const eventId = "$test:event";
|
||||
httpBackend
|
||||
.when("PUT", "/txn1")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "test message", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
|
||||
it("should include queryParams in sync requests", async () => {
|
||||
httpBackend
|
||||
.when("GET", "/versions")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/pushrules")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
httpBackend
|
||||
.when("POST", "/filter")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, { filter_id: "foo" });
|
||||
|
||||
httpBackend
|
||||
.when("GET", "/sync")
|
||||
.check((req) => {
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, syncData);
|
||||
|
||||
client.startClient();
|
||||
await httpBackend.flush("/versions", 1);
|
||||
await httpBackend.flush("/pushrules", 1);
|
||||
await httpBackend.flush("/filter", 1);
|
||||
await Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]);
|
||||
});
|
||||
|
||||
it("should merge queryParams with request-specific params", async () => {
|
||||
const eventId = "$test:event";
|
||||
httpBackend
|
||||
.when("PUT", "/txn1")
|
||||
.check((req) => {
|
||||
// Should contain both global queryParams and request-specific params
|
||||
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
|
||||
return true;
|
||||
})
|
||||
.respond(200, {
|
||||
event_id: eventId,
|
||||
});
|
||||
|
||||
const [res] = await Promise.all([
|
||||
client.sendTextMessage("!foo:bar", "test message", "txn1"),
|
||||
httpBackend.flush("/txn1", 1),
|
||||
]);
|
||||
|
||||
expect(res.event_id).toEqual(eventId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
PayloadType,
|
||||
RendezvousError,
|
||||
} from "../../../src/rendezvous";
|
||||
import { defer } from "../../../src/utils";
|
||||
import {
|
||||
ClientPrefix,
|
||||
DEVICE_CODE_SCOPE,
|
||||
@@ -112,8 +111,8 @@ describe("MSC4108SignInWithQR", () => {
|
||||
let opponentLogin: MSC4108SignInWithQR;
|
||||
|
||||
beforeEach(async () => {
|
||||
let ourData = defer<string>();
|
||||
let opponentData = defer<string>();
|
||||
let ourData = Promise.withResolvers<string>();
|
||||
let opponentData = Promise.withResolvers<string>();
|
||||
|
||||
const ourMockSession = {
|
||||
send: jest.fn(async (newData) => {
|
||||
@@ -122,7 +121,7 @@ describe("MSC4108SignInWithQR", () => {
|
||||
receive: jest.fn(() => {
|
||||
const prom = opponentData.promise;
|
||||
prom.then(() => {
|
||||
opponentData = defer();
|
||||
opponentData = Promise.withResolvers();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
@@ -141,7 +140,7 @@ describe("MSC4108SignInWithQR", () => {
|
||||
receive: jest.fn(() => {
|
||||
const prom = ourData.promise;
|
||||
prom.then(() => {
|
||||
ourData = defer();
|
||||
ourData = Promise.withResolvers();
|
||||
});
|
||||
return prom;
|
||||
}),
|
||||
@@ -334,11 +333,11 @@ describe("MSC4108SignInWithQR", () => {
|
||||
// @ts-ignore
|
||||
await opponentLogin.receive();
|
||||
|
||||
const deferred = defer<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deferred.promise);
|
||||
const deviceResolvers = Promise.withResolvers<IMyDevice>();
|
||||
mocked(client.getDevice).mockReturnValue(deviceResolvers.promise);
|
||||
|
||||
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
|
||||
deferred.resolve({} as IMyDevice);
|
||||
deviceResolvers.resolve({} as IMyDevice);
|
||||
|
||||
const secrets = {
|
||||
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
|
||||
|
||||
@@ -48,7 +48,6 @@ import { type SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { type IStoredClientOpts } from "../../src";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { defer } from "../../src/utils";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { type SyncCryptoCallbacks } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
@@ -127,7 +126,7 @@ describe("SlidingSyncSdk", () => {
|
||||
// assign client/httpBackend globals
|
||||
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
|
||||
testOpts = testOpts || {};
|
||||
const syncOpts: SyncApiOptions = {};
|
||||
const syncOpts: SyncApiOptions = { logger };
|
||||
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
||||
httpBackend = testClient.httpBackend;
|
||||
client = testClient.client;
|
||||
@@ -369,7 +368,7 @@ describe("SlidingSyncSdk", () => {
|
||||
});
|
||||
|
||||
it("can be created with live events", async () => {
|
||||
const seenLiveEventDeferred = defer<boolean>();
|
||||
const seenLiveEventDeferred = Promise.withResolvers<boolean>();
|
||||
const listener = (
|
||||
ev: MatrixEvent,
|
||||
room?: Room,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
Copyright 2017 Vector creations Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../src/logger";
|
||||
|
||||
// try to load the olm library.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
globalThis.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
}
|
||||
@@ -19,3 +19,8 @@ jest.mock("../src/http-api/utils", () => ({
|
||||
// We mock timeoutSignal otherwise it causes tests to leave timers running
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
}));
|
||||
|
||||
// Dont make test fail too soon due to timeouts while debugging.
|
||||
if (process.env.VSCODE_INSPECTOR_OPTIONS) {
|
||||
jest.setTimeout(60 * 1000 * 5); // 5 minutes
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import debugFunc from "debug";
|
||||
import { type Debugger } from "debug";
|
||||
import debugFunc, { type Debugger } from "debug";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
|
||||
import type { CrossSigningKeys, ISignedKey, KeySignatures } from "../../src";
|
||||
import type { CrossSigningKeyInfo } from "../../src/crypto-api";
|
||||
|
||||
/** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys
|
||||
*
|
||||
@@ -55,19 +56,27 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
private readonly debug: Debugger;
|
||||
|
||||
private deviceKeys: IDeviceKeys | null = null;
|
||||
private crossSigningKeys: CrossSigningKeys | null = null;
|
||||
private oneTimeKeys: Record<string, IOneTimeKey> = {};
|
||||
private readonly oneTimeKeysPromise: Promise<void>;
|
||||
|
||||
/**
|
||||
* Construct a new E2EKeyReceiver.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl.
|
||||
* Only /upload requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* It will immediately register an intercept of [`/keys/upload`][1], [`/keys/signatures/upload`][2] and
|
||||
* [`/keys/device_signing/upload`][3] requests for the given homeserverUrl.
|
||||
* Only requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
* [1]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysupload
|
||||
* [2]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keyssignaturesupload
|
||||
* [3]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysdevice_signingupload
|
||||
*
|
||||
* @param homeserverUrl - The Homeserver Url of the client under test.
|
||||
* @param routeNamePrefix - An optional prefix to add to the fetchmock route names. Required if there is more than
|
||||
* one E2EKeyReceiver instance active.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
public constructor(homeserverUrl: string, routeNamePrefix: string = "") {
|
||||
this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`);
|
||||
|
||||
// set up a listener for /keys/upload.
|
||||
@@ -77,6 +86,22 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
|
||||
});
|
||||
|
||||
fetchMock.post(
|
||||
{
|
||||
url: new URL("/_matrix/client/v3/keys/signatures/upload", homeserverUrl).toString(),
|
||||
name: routeNamePrefix + "upload-sigs",
|
||||
},
|
||||
(url, options) => this.onSignaturesUploadRequest(options),
|
||||
);
|
||||
|
||||
fetchMock.post(
|
||||
{
|
||||
url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(),
|
||||
name: routeNamePrefix + "upload-cross-signing-keys",
|
||||
},
|
||||
(url, options) => this.onSigningKeyUploadRequest(options),
|
||||
);
|
||||
}
|
||||
|
||||
private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise<object> {
|
||||
@@ -87,8 +112,10 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
if (this.deviceKeys) {
|
||||
throw new Error("Application attempted to upload E2E device keys multiple times");
|
||||
}
|
||||
this.debug(`received device keys`);
|
||||
this.deviceKeys = content.device_keys;
|
||||
this.debug(
|
||||
`received device keys for user ID ${this.deviceKeys!.user_id}, device ID ${this.deviceKeys!.device_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) {
|
||||
@@ -113,6 +140,47 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
};
|
||||
}
|
||||
|
||||
private async onSignaturesUploadRequest(request: RequestInit): Promise<object> {
|
||||
const content = JSON.parse(request.body as string) as KeySignatures;
|
||||
for (const [userId, userKeys] of Object.entries(content)) {
|
||||
for (const [deviceId, signedKey] of Object.entries(userKeys)) {
|
||||
this.onDeviceSignatureUpload(userId, deviceId, signedKey);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private onDeviceSignatureUpload(userId: string, deviceId: string, signedKey: CrossSigningKeyInfo | ISignedKey) {
|
||||
if (!this.deviceKeys || userId != this.deviceKeys.user_id || deviceId != this.deviceKeys.device_id) {
|
||||
this.debug(
|
||||
`Ignoring device key signature upload for unknown device user ID ${userId}, device ID ${deviceId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.debug(`received device key signature for user ID ${userId}, device ID ${deviceId}`);
|
||||
this.deviceKeys.signatures ??= {};
|
||||
for (const [signingUser, signatures] of Object.entries(signedKey.signatures!)) {
|
||||
this.deviceKeys.signatures[signingUser] = Object.assign(
|
||||
this.deviceKeys.signatures[signingUser] ?? {},
|
||||
signatures,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async onSigningKeyUploadRequest(request: RequestInit): Promise<object> {
|
||||
const content = JSON.parse(request.body as string);
|
||||
if (this.crossSigningKeys) {
|
||||
throw new Error("Application attempted to upload E2E cross-signing keys multiple times");
|
||||
}
|
||||
this.debug(`received cross-signing keys`);
|
||||
// Remove UIA data
|
||||
delete content["auth"];
|
||||
this.crossSigningKeys = content;
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Get the uploaded Ed25519 key
|
||||
*
|
||||
* If device keys have not yet been uploaded, throws an error
|
||||
@@ -150,6 +218,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
return this.deviceKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If cross-signing keys have been uploaded, return them. Else return null.
|
||||
*/
|
||||
public getUploadedCrossSigningKeys(): CrossSigningKeys | null {
|
||||
return this.crossSigningKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If one-time keys have already been uploaded, return them. Otherwise,
|
||||
* set up an expectation that the keys will be uploaded, and wait for
|
||||
@@ -161,4 +236,18 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
||||
await this.oneTimeKeysPromise;
|
||||
return this.oneTimeKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* If no one-time keys have yet been uploaded, return `null`.
|
||||
* Otherwise, pop a key from the uploaded list.
|
||||
*/
|
||||
public getOneTimeKey(): [string, IOneTimeKey] | null {
|
||||
const keys = Object.entries(this.oneTimeKeys);
|
||||
if (keys.length == 0) {
|
||||
return null;
|
||||
}
|
||||
const [otkId, otk] = keys[0];
|
||||
delete this.oneTimeKeys[otkId];
|
||||
return [otkId, otk];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MapWithDefault } from "../../src/utils";
|
||||
import { type IDownloadKeyResult } from "../../src";
|
||||
import { type IDownloadKeyResult, type SigningKeys } from "../../src";
|
||||
import { type IDeviceKeys } from "../../src/@types/crypto";
|
||||
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
|
||||
@@ -50,18 +50,14 @@ export class E2EKeyResponder {
|
||||
const content = JSON.parse(options.body as string);
|
||||
const usersToReturn = Object.keys(content["device_keys"]);
|
||||
const response = {
|
||||
device_keys: {} as { [userId: string]: any },
|
||||
master_keys: {} as { [userId: string]: any },
|
||||
self_signing_keys: {} as { [userId: string]: any },
|
||||
user_signing_keys: {} as { [userId: string]: any },
|
||||
failures: {} as { [serverName: string]: any },
|
||||
};
|
||||
device_keys: {},
|
||||
master_keys: {},
|
||||
self_signing_keys: {},
|
||||
user_signing_keys: {},
|
||||
failures: {},
|
||||
} as IDownloadKeyResult;
|
||||
for (const user of usersToReturn) {
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] = Object.fromEntries(userKeys.entries());
|
||||
}
|
||||
|
||||
// First see if we have an E2EKeyReceiver for this user, and if so, return any keys that have been uploaded
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
||||
if (e2eKeyReceiver !== undefined) {
|
||||
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
||||
@@ -69,16 +65,27 @@ export class E2EKeyResponder {
|
||||
response.device_keys[user] ??= {};
|
||||
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
|
||||
}
|
||||
const crossSigningKeys = e2eKeyReceiver.getUploadedCrossSigningKeys();
|
||||
if (crossSigningKeys !== null) {
|
||||
response.master_keys![user] = crossSigningKeys["master_key"];
|
||||
response.self_signing_keys![user] = crossSigningKeys["self_signing_key"] as SigningKeys;
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in any keys that have been added explicitly to this E2EKeyResponder.
|
||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
||||
if (userKeys !== undefined) {
|
||||
response.device_keys[user] ??= {};
|
||||
Object.assign(response.device_keys[user], Object.fromEntries(userKeys.entries()));
|
||||
}
|
||||
if (this.masterKeysByUser.hasOwnProperty(user)) {
|
||||
response.master_keys[user] = this.masterKeysByUser[user];
|
||||
response.master_keys![user] = this.masterKeysByUser[user];
|
||||
}
|
||||
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
|
||||
response.self_signing_keys![user] = this.selfSigningKeysByUser[user];
|
||||
}
|
||||
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
|
||||
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
|
||||
response.user_signing_keys![user] = this.userSigningKeysByUser[user];
|
||||
}
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { MapWithDefault } from "../../src/utils";
|
||||
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||
import { type IClaimKeysRequest } from "../../src";
|
||||
|
||||
/**
|
||||
* An object which intercepts `/keys/claim` fetches via fetch-mock.
|
||||
*/
|
||||
export class E2EOTKClaimResponder {
|
||||
private e2eKeyReceiversByUserByDevice = new MapWithDefault<string, Map<string, E2EKeyReceiver>>(() => new Map());
|
||||
|
||||
/**
|
||||
* Construct a new E2EOTKClaimResponder.
|
||||
*
|
||||
* It will immediately register an intercept of `/keys/claim` requests for the given homeserverUrl.
|
||||
* Only /claim requests made to this server will be intercepted: this allows a single test to use more than one
|
||||
* client and have the keys collected separately.
|
||||
*
|
||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
||||
*/
|
||||
public constructor(homeserverUrl: string) {
|
||||
const listener = (url: string, options: RequestInit) => this.onKeyClaimRequest(options);
|
||||
fetchMock.post(new URL("/_matrix/client/v3/keys/claim", homeserverUrl).toString(), listener);
|
||||
}
|
||||
|
||||
private onKeyClaimRequest(options: RequestInit) {
|
||||
const content = JSON.parse(options.body as string) as IClaimKeysRequest;
|
||||
const response = {
|
||||
one_time_keys: {} as { [userId: string]: any },
|
||||
};
|
||||
for (const [userId, devices] of Object.entries(content["one_time_keys"])) {
|
||||
for (const deviceId of Object.keys(devices)) {
|
||||
const e2eKeyReceiver = this.e2eKeyReceiversByUserByDevice.get(userId)?.get(deviceId);
|
||||
const otk = e2eKeyReceiver?.getOneTimeKey();
|
||||
if (otk) {
|
||||
const [keyId, key] = otk;
|
||||
response.one_time_keys[userId] ??= {};
|
||||
response.one_time_keys[userId][deviceId] = {
|
||||
[keyId]: key,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an E2EKeyReceiver to poll for uploaded keys
|
||||
*
|
||||
* When the `/keys/claim` request is received, a OTK will be removed from the `E2EKeyReceiver` and
|
||||
* added to the response.
|
||||
*/
|
||||
public addKeyReceiver(userId: string, deviceId: string, e2eKeyReceiver: E2EKeyReceiver) {
|
||||
this.e2eKeyReceiversByUserByDevice.getOrCreate(userId).set(deviceId, e2eKeyReceiver);
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,9 @@ export function mockInitialApiRequests(homeserverUrl: string, userId: string = "
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
* Mock the requests needed to set up cross signing, besides those provided by {@link E2EKeyReceiver}.
|
||||
*
|
||||
* Return 404 error for `GET _matrix/client/v3/user/:userId/account_data/:type` request
|
||||
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
|
||||
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
|
||||
*/
|
||||
export function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
@@ -55,19 +53,6 @@ export function mockSetupCrossSigningRequests(): void {
|
||||
status: 404,
|
||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
||||
});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// load olm before the sdk if possible
|
||||
import "../olm-loader";
|
||||
import mkdebug from "debug";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type EventEmitter from "events";
|
||||
import { logger } from "../../src/logger";
|
||||
import {
|
||||
type IContent,
|
||||
type IEvent,
|
||||
@@ -27,6 +25,8 @@ import { eventMapperFor } from "../../src/event-mapper";
|
||||
import { TEST_ROOM_ID } from "./test-data";
|
||||
import { KnownMembership, type Membership } from "../../src/@types/membership";
|
||||
|
||||
const debug = mkdebug("test-utils");
|
||||
|
||||
/**
|
||||
* Return a promise that is resolved when the client next emits a
|
||||
* SYNCING event.
|
||||
@@ -41,7 +41,7 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
|
||||
|
||||
const p = new Promise<void>((resolve) => {
|
||||
const cb = (state: SyncState) => {
|
||||
logger.log(`${Date.now()} syncPromise(${count}): ${state}`);
|
||||
debug(`syncPromise(${count}): ${state}`);
|
||||
if (state === SyncState.Syncing) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -522,25 +522,25 @@ export async function awaitDecryption(
|
||||
// already
|
||||
if (event.getClearContent() !== null) {
|
||||
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
|
||||
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
|
||||
debug(`event ${event.getId()} got decryption error; waiting`);
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
} else {
|
||||
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
|
||||
debug(`event ${event.getId()} is not yet decrypted; waiting`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (waitOnDecryptionFailure) {
|
||||
event.on(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
debug(`MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
if (!err) {
|
||||
resolve(ev);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
|
||||
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
debug(`MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
|
||||
resolve(ev);
|
||||
});
|
||||
}
|
||||
@@ -592,3 +592,98 @@ export async function advanceTimersUntil<T>(promise: Promise<T>): Promise<T> {
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
export function jestFakeTimersAreEnabled(): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(setTimeout, "clock");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `callback` in a loop, until it returns a successful result (i.e. it does not throw), or we reach a timeout
|
||||
*
|
||||
* Based on the function of the same name in the {@link https://testing-library.com/docs/dom-testing-library/api-async/#waitfor DOM testing library}.
|
||||
*
|
||||
* @param callback - The function to call to check if we can proceed. If it returns a result (including a falsey one),
|
||||
* `waitFor` returns that result. If it throws, `waitFor` continues to wait.
|
||||
*
|
||||
* May return a promise, in which case no further checks are done until the promise resolves.
|
||||
*
|
||||
* @param timeout - The time to wait for, overall, in ms. If `callback` still hasn't returned a successful result after
|
||||
* this time, `waitFor` will throw an error.
|
||||
*
|
||||
* Defaults to 1000.
|
||||
*
|
||||
* @param interval - How often to call `callback`. Defaults to 50.
|
||||
*/
|
||||
export function waitFor<T>(
|
||||
callback: () => Promise<T> | T,
|
||||
{
|
||||
timeout = 1000,
|
||||
interval = 50,
|
||||
}: {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let lastError: any;
|
||||
let finished = false;
|
||||
let intervalId: ReturnType<typeof setTimeout> | undefined;
|
||||
let promisePending = false;
|
||||
|
||||
const overallTimeoutTimer = setTimeout(handleTimeout, timeout);
|
||||
const usingJestFakeTimers = jestFakeTimersAreEnabled();
|
||||
if (usingJestFakeTimers) {
|
||||
checkCallback();
|
||||
|
||||
while (!finished) {
|
||||
jest.advanceTimersByTime(interval);
|
||||
|
||||
// Could have timed-out
|
||||
if (finished) break;
|
||||
|
||||
checkCallback();
|
||||
}
|
||||
} else {
|
||||
intervalId = setInterval(checkCallback, interval);
|
||||
checkCallback();
|
||||
}
|
||||
|
||||
function checkCallback() {
|
||||
if (promisePending) {
|
||||
// still waiting for the previous check
|
||||
return;
|
||||
}
|
||||
|
||||
async function doCheck() {
|
||||
try {
|
||||
const result = await callback();
|
||||
onDone();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
// Save the most recent callback error to reject the promise with it in the event of a timeout
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
promisePending = true;
|
||||
doCheck().finally(() => {
|
||||
promisePending = false;
|
||||
});
|
||||
}
|
||||
|
||||
function onDone(): void {
|
||||
finished = true;
|
||||
clearTimeout(overallTimeoutTimer);
|
||||
if (intervalId !== undefined) clearInterval(intervalId);
|
||||
}
|
||||
|
||||
function handleTimeout() {
|
||||
onDone();
|
||||
if (lastError) {
|
||||
reject(lastError);
|
||||
} else {
|
||||
reject(new Error("Timed out in waitFor."));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
import { type IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { defer } from "../../src/utils";
|
||||
import { logger } from "../../src/logger.ts";
|
||||
|
||||
describe("onResumedSync", () => {
|
||||
let batch: IndexedToDeviceBatch | null;
|
||||
@@ -56,11 +56,11 @@ describe("onResumedSync", () => {
|
||||
}
|
||||
});
|
||||
|
||||
queue = new ToDeviceMessageQueue(mockClient);
|
||||
queue = new ToDeviceMessageQueue(mockClient, logger);
|
||||
});
|
||||
|
||||
it("resends queue after connectivity restored", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -73,15 +73,15 @@ describe("onResumedSync", () => {
|
||||
onSendToDeviceSuccess = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(3);
|
||||
expect(store.removeToDeviceBatch).toHaveBeenCalled();
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if client sync still catching up", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -89,15 +89,15 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Catchup, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
|
||||
it("does not resend queue if connectivity restored after queue stopped", async () => {
|
||||
const deferred = defer();
|
||||
const successResolvers = Promise.withResolvers<void>();
|
||||
|
||||
onSendToDeviceFailure = () => {
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
@@ -107,10 +107,10 @@ describe("onResumedSync", () => {
|
||||
|
||||
resumeSync(SyncState.Syncing, SyncState.Catchup);
|
||||
expect(store.getOldestToDeviceBatch).toHaveBeenCalledTimes(1);
|
||||
deferred.resolve();
|
||||
successResolvers.resolve();
|
||||
};
|
||||
|
||||
queue.start();
|
||||
return deferred.promise;
|
||||
return successResolvers.promise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,14 +196,14 @@ describe("Topic content helpers", () => {
|
||||
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
|
||||
topic: "pizza",
|
||||
[M_TOPIC.name]: [
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
{
|
||||
body: "<b>pizza</b>",
|
||||
mimetype: "text/html",
|
||||
},
|
||||
{
|
||||
body: "pizza",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
+159
-21
@@ -28,10 +28,12 @@ import {
|
||||
WidgetApiToWidgetAction,
|
||||
MatrixCapabilities,
|
||||
type ITurnServer,
|
||||
type IRoomEvent,
|
||||
type IOpenIDCredentials,
|
||||
type ISendEventFromWidgetResponseData,
|
||||
WidgetApiResponseError,
|
||||
UnstableApiVersion,
|
||||
type ApiVersion,
|
||||
type IRoomEvent,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
|
||||
@@ -41,6 +43,9 @@ import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
|
||||
import { MatrixEvent } from "../../src/models/event";
|
||||
import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { sleep } from "../../src/utils";
|
||||
import { SlidingSync } from "../../src/sliding-sync";
|
||||
import { logger } from "../../src/logger";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
|
||||
const testOIDCToken = {
|
||||
access_token: "12345678",
|
||||
@@ -50,6 +55,7 @@ const testOIDCToken = {
|
||||
};
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn().mockResolvedValue(undefined);
|
||||
public getClientVersions = jest.fn();
|
||||
public requestCapability = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
|
||||
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -97,6 +103,15 @@ class MockWidgetApi extends EventEmitter {
|
||||
send: jest.fn(),
|
||||
sendComplete: jest.fn(),
|
||||
};
|
||||
|
||||
/**
|
||||
* This mocks the widget's view of what is supported by its environment.
|
||||
* @param clientVersions The versions that the widget believes are supported by the host client's widget driver.
|
||||
*/
|
||||
public constructor(clientVersions: ApiVersion[]) {
|
||||
super();
|
||||
this.getClientVersions.mockResolvedValue(clientVersions);
|
||||
}
|
||||
}
|
||||
|
||||
declare module "../../src/types" {
|
||||
@@ -118,7 +133,7 @@ describe("RoomWidgetClient", () => {
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
|
||||
widgetApi = new MockWidgetApi([UnstableApiVersion.MSC2762_UPDATE_STATE]) as unknown as MockedObject<WidgetApi>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -129,6 +144,7 @@ describe("RoomWidgetClient", () => {
|
||||
capabilities: ICapabilities,
|
||||
sendContentLoaded: boolean | undefined = undefined,
|
||||
userId?: string,
|
||||
useSlidingSync?: boolean,
|
||||
): Promise<void> => {
|
||||
const baseUrl = "https://example.org";
|
||||
client = createRoomWidgetClient(
|
||||
@@ -140,7 +156,7 @@ describe("RoomWidgetClient", () => {
|
||||
);
|
||||
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
|
||||
widgetApi.emit("ready");
|
||||
await client.startClient();
|
||||
await client.startClient(useSlidingSync ? { slidingSync: new SlidingSync("", new Map(), {}, client, 0) } : {});
|
||||
};
|
||||
|
||||
describe("events", () => {
|
||||
@@ -634,12 +650,20 @@ describe("RoomWidgetClient", () => {
|
||||
});
|
||||
|
||||
it("receives", async () => {
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
// Client needs to be told that the room state is loaded
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),
|
||||
);
|
||||
await init;
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
||||
// Let's assume that a state event comes in but it doesn't actually
|
||||
// update the state of the room just yet (maybe it's unauthorized)
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
@@ -648,26 +672,139 @@ describe("RoomWidgetClient", () => {
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
// It should've also inserted the event into the room object
|
||||
// However it should not have changed the room state
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
|
||||
|
||||
// Now assume that the state event becomes favored by state
|
||||
// resolution for whatever reason and enters into the current state
|
||||
// of the room
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {
|
||||
detail: { data: { state: [event] } },
|
||||
}),
|
||||
);
|
||||
// Allow the getClientVersions promise to resolve
|
||||
await flushPromises();
|
||||
// It should now have changed the room state
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
|
||||
it("backfills", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
describe("without support for update_state", () => {
|
||||
beforeEach(() => {
|
||||
widgetApi = new MockWidgetApi([]) as unknown as MockedObject<WidgetApi>;
|
||||
});
|
||||
|
||||
it("receives", async () => {
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
// It should've also inserted the event into the room object
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
|
||||
it("does not receive with sliding sync (update_state is needed for sliding sync)", async () => {
|
||||
await makeClient(
|
||||
{ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
||||
const logSpy = jest.spyOn(logger, "error");
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
|
||||
// The incompatibility of sliding sync without update_state to get logged.
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
|
||||
);
|
||||
// It should not have inserted the event into the room object
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")).toEqual(null);
|
||||
});
|
||||
|
||||
it("backfills", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
);
|
||||
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
it("backfills with sliding sync", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
);
|
||||
await makeClient(
|
||||
{ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores state updates for other rooms", async () => {
|
||||
const init = makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
// Client needs to be told that the room state is loaded
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, { detail: { data: { state: [] } } }),
|
||||
);
|
||||
await init;
|
||||
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
||||
// Now a room we're not interested in receives a state update
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.UpdateState}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.UpdateState}`, {
|
||||
detail: { data: { state: [{ ...event, room_id: "!other-room:example.org" }] } },
|
||||
}),
|
||||
);
|
||||
// No change to the room state
|
||||
for (const room of client.getRooms()) {
|
||||
expect(room.currentState.getStateEvents("org.example.foo", "bar")).toBe(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -722,13 +859,14 @@ describe("RoomWidgetClient", () => {
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
it("sends encrypted (encryptAndSendToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const payload = { type: "org.example.foo", hello: "world" };
|
||||
const payload = { hello: "world" };
|
||||
const embeddedClient = client as RoomWidgetClient;
|
||||
await embeddedClient.encryptAndSendToDevices(
|
||||
await embeddedClient.encryptAndSendToDevice(
|
||||
"org.example.foo",
|
||||
[
|
||||
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
|
||||
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
|
||||
|
||||
@@ -170,4 +170,11 @@ describe("Filter Component", function () {
|
||||
expect(filter.check(noMatchEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON", () => {
|
||||
it("should omit empty values", () => {
|
||||
const filter = new FilterComponent({ types: ["m.room.message"], senders: ["@alice:example.com"] });
|
||||
expect(filter.toJSON()).toEqual({ types: ["m.room.message"], senders: ["@alice:example.com"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import type { Mocked, MockedFunction } from "jest-mock";
|
||||
import { FetchHttpApi } from "../../../src/http-api/fetch";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import {
|
||||
@@ -29,17 +28,22 @@ import {
|
||||
Method,
|
||||
} from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { defer, type QueryDict } from "../../../src/utils";
|
||||
import { type QueryDict, sleep } from "../../../src/utils";
|
||||
import { type Logger } from "../../../src/logger";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
const idBaseUrl = "http://idBaseUrl";
|
||||
const prefix = ClientPrefix.V3;
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should support aborting multiple times", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
|
||||
api.request(Method.Get, "/foo");
|
||||
api.request(Method.Get, "/baz");
|
||||
@@ -63,13 +67,13 @@ describe("FetchHttpApi", () => {
|
||||
it("should fall back to global fetch if fetchFn not provided", () => {
|
||||
globalThis.fetch = jest.fn();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
api.fetch("test");
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update identity server base url", () => {
|
||||
const api = new FetchHttpApi<IHttpOpts>(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new FetchHttpApi<IHttpOpts>(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
expect(api.opts.idBaseUrl).toBeUndefined();
|
||||
api.setIdBaseUrl("https://id.foo.bar");
|
||||
expect(api.opts.idBaseUrl).toBe("https://id.foo.bar");
|
||||
@@ -77,23 +81,35 @@ describe("FetchHttpApi", () => {
|
||||
|
||||
describe("idServerRequest", () => {
|
||||
it("should throw if no idBaseUrl", () => {
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
expect(() => api.idServerRequest(Method.Get, "/test", {}, IdentityPrefix.V2)).toThrow(
|
||||
"No identity server base URL set",
|
||||
);
|
||||
});
|
||||
|
||||
it("should send params as query string for GET requests", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
idBaseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
onlyData: true,
|
||||
});
|
||||
api.idServerRequest(Method.Get, "/test", { foo: "bar", via: ["a", "b"] }, IdentityPrefix.V2);
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).toBe("bar");
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.getAll("via")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("should send params as body for non-GET requests", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
idBaseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
onlyData: true,
|
||||
});
|
||||
const params = { foo: "bar", via: ["a", "b"] };
|
||||
api.idServerRequest(Method.Post, "/test", params, IdentityPrefix.V2);
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.get("foo")).not.toBe("bar");
|
||||
@@ -101,21 +117,38 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should add Authorization header if token provided", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, idBaseUrl, prefix, fetchFn });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
idBaseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
onlyData: true,
|
||||
});
|
||||
api.idServerRequest(Method.Post, "/test", {}, IdentityPrefix.V2, "token");
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the Response object if onlyData=false", async () => {
|
||||
const res = { ok: true };
|
||||
const fetchFn = jest.fn().mockResolvedValue(res);
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: false });
|
||||
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res);
|
||||
it("should complain if constructed without `onlyData: true`", async () => {
|
||||
expect(
|
||||
() =>
|
||||
new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
}),
|
||||
).toThrow("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
|
||||
});
|
||||
|
||||
it("should return text if json=false", async () => {
|
||||
it("should set an Accept header, and parse the response as JSON, by default", async () => {
|
||||
const result = { a: 1 };
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(result) });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(result);
|
||||
expect(fetchFn.mock.calls[0][1].headers.Accept).toBe("application/json");
|
||||
});
|
||||
|
||||
it("should not set an Accept header, and should return text if json=false", async () => {
|
||||
const text = "418 I'm a teapot";
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
@@ -124,40 +157,68 @@ describe("FetchHttpApi", () => {
|
||||
json: false,
|
||||
}),
|
||||
).resolves.toBe(text);
|
||||
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("should not set an Accept header, and should return a blob, if rawResponseBody is true", async () => {
|
||||
const blob = new Blob(["blobby"]);
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, blob: jest.fn().mockResolvedValue(blob) });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
await expect(
|
||||
api.requestOtherUrl(Method.Get, "http://url", undefined, {
|
||||
rawResponseBody: true,
|
||||
}),
|
||||
).resolves.toBe(blob);
|
||||
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("should throw an error if both `json` and `rawResponseBody` are defined", async () => {
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn: jest.fn(),
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(
|
||||
api.requestOtherUrl(Method.Get, "http://url", undefined, { rawResponseBody: false, json: true }),
|
||||
).rejects.toThrow("Invalid call to `FetchHttpApi`");
|
||||
});
|
||||
|
||||
it("should send token via query params if useAuthorizationHeader=false", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken: "token",
|
||||
useAuthorizationHeader: false,
|
||||
onlyData: true,
|
||||
});
|
||||
await api.authedRequest(Method.Get, "/path");
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("token");
|
||||
});
|
||||
|
||||
it("should send token via headers by default", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken: "token",
|
||||
onlyData: true,
|
||||
});
|
||||
await api.authedRequest(Method.Get, "/path");
|
||||
expect(fetchFn.mock.calls[0][1].headers["Authorization"]).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("should not send a token if not calling `authedRequest`", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken: "token",
|
||||
onlyData: true,
|
||||
});
|
||||
api.request(Method.Get, "/path");
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
|
||||
@@ -165,13 +226,14 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should ensure no token is leaked out via query params if sending via headers", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken: "token",
|
||||
useAuthorizationHeader: true,
|
||||
onlyData: true,
|
||||
});
|
||||
await api.authedRequest(Method.Get, "/path", { access_token: "123" });
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBeFalsy();
|
||||
@@ -179,26 +241,28 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should not override manually specified access token via query params", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken: "token",
|
||||
useAuthorizationHeader: false,
|
||||
onlyData: true,
|
||||
});
|
||||
await api.authedRequest(Method.Get, "/path", { access_token: "RealToken" });
|
||||
expect(fetchFn.mock.calls[0][0].searchParams.get("access_token")).toBe("RealToken");
|
||||
});
|
||||
|
||||
it("should not override manually specified access token via header", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken: "token",
|
||||
useAuthorizationHeader: true,
|
||||
onlyData: true,
|
||||
});
|
||||
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
|
||||
headers: { Authorization: "Bearer RealToken" },
|
||||
@@ -207,8 +271,8 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should not override Accept header", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
await api.authedRequest(Method.Get, "/path", undefined, undefined, {
|
||||
headers: { Accept: "text/html" },
|
||||
});
|
||||
@@ -231,7 +295,7 @@ describe("FetchHttpApi", () => {
|
||||
),
|
||||
});
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
|
||||
await Promise.all([
|
||||
emitPromise(emitter, HttpApiEvent.NoConsent),
|
||||
@@ -241,9 +305,9 @@ describe("FetchHttpApi", () => {
|
||||
|
||||
describe("authedRequest", () => {
|
||||
it("should not include token if unset", async () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const fetchFn = makeMockFetchFn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn });
|
||||
const api = new FetchHttpApi(emitter, { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
});
|
||||
@@ -272,6 +336,7 @@ describe("FetchHttpApi", () => {
|
||||
const okayResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: jest.fn().mockResolvedValue({ x: 1 }),
|
||||
};
|
||||
|
||||
describe("without a tokenRefreshFunction", () => {
|
||||
@@ -279,7 +344,14 @@ describe("FetchHttpApi", () => {
|
||||
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 });
|
||||
const api = new FetchHttpApi(emitter, {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
@@ -301,6 +373,7 @@ describe("FetchHttpApi", () => {
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
@@ -322,6 +395,7 @@ describe("FetchHttpApi", () => {
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
@@ -350,9 +424,12 @@ describe("FetchHttpApi", () => {
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
const result = await api.authedRequest(Method.Post, "/account/password");
|
||||
expect(result).toEqual(okayResponse);
|
||||
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
|
||||
headers: {},
|
||||
});
|
||||
expect(result).toEqual({ x: 1 });
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
@@ -361,12 +438,22 @@ describe("FetchHttpApi", () => {
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should only try to refresh the token once", async () => {
|
||||
it("should not try to refresh the token if it has plenty of time left before expiry", async () => {
|
||||
// We can't specify an expiry for the initial token, so this should:
|
||||
// * Try once, fail
|
||||
// * Attempt a refresh, get a token that's not expired
|
||||
// * Try again, still fail
|
||||
// * Not refresh the token because it's not expired
|
||||
// ...which is TWO attempts and ONE refresh (which doesn't really
|
||||
// count because it's only to get a token with an expiry)
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValue({
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
// This needs to be sufficiently high that it's over the threshold for
|
||||
// 'plenty of time' (which is a minute in practice).
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
// fetch doesn't like our new or old tokens
|
||||
@@ -381,12 +468,13 @@ describe("FetchHttpApi", () => {
|
||||
tokenRefreshFunction,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// tried to refresh the token once
|
||||
// tried to refresh the token once (to get the one with an expiry)
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -397,6 +485,55 @@ describe("FetchHttpApi", () => {
|
||||
// logged out after refreshed access token is rejected
|
||||
expect(emitter.emit).toHaveBeenCalledWith(HttpApiEvent.SessionLoggedOut, unknownTokenErr);
|
||||
});
|
||||
|
||||
it("should try to refresh the token if it will expire soon", async () => {
|
||||
const newAccessToken = "new-access-token";
|
||||
const newRefreshToken = "new-refresh-token";
|
||||
|
||||
// first refresh is to get a token with an expiry at all, because we
|
||||
// can't specify an expiry on the token we inject
|
||||
const tokenRefreshFunction = jest.fn().mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// next refresh is to return a token that will expire 'soon'
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
// ...and finally we return a token that has adequate time left
|
||||
// so that it will cease retrying and fail the request.
|
||||
tokenRefreshFunction.mockResolvedValueOnce({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiry: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
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,
|
||||
onlyData: true,
|
||||
});
|
||||
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
|
||||
unknownTokenErr,
|
||||
);
|
||||
|
||||
// We should have seen the 3 token refreshes, as above.
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -408,7 +545,7 @@ describe("FetchHttpApi", () => {
|
||||
const makeApi = (thisBaseUrl = baseUrl): FetchHttpApi<any> => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn });
|
||||
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn, onlyData: true });
|
||||
};
|
||||
|
||||
type TestParams = {
|
||||
@@ -456,12 +593,95 @@ describe("FetchHttpApi", () => {
|
||||
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
|
||||
runTests(baseUrlWithTrailingSlash);
|
||||
});
|
||||
|
||||
describe("extraParams handling", () => {
|
||||
const makeApiWithExtraParams = (extraParams: QueryDict): FetchHttpApi<any> => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
return new FetchHttpApi(emitter, {
|
||||
baseUrl: localBaseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
onlyData: true,
|
||||
extraParams,
|
||||
});
|
||||
};
|
||||
|
||||
const userId = "@rsb-tbg:localhost";
|
||||
const encodedUserId = encodeURIComponent(userId);
|
||||
|
||||
it("should include extraParams in URL when no queryParams provided", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const result = api.getUrl("/test");
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
|
||||
});
|
||||
|
||||
it("should merge extraParams with queryParams", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { userId: "123", filter: "active" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("user_id")!).toBe(userId);
|
||||
expect(result.searchParams.get("version")!).toBe("1.0");
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.searchParams.get("filter")!).toBe("active");
|
||||
});
|
||||
|
||||
it("should allow queryParams to override extraParams", () => {
|
||||
const extraParams = { user_id: "@default:localhost", version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { user_id: "@override:localhost", userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("user_id")).toBe("@override:localhost");
|
||||
expect(result.searchParams.get("version")!).toBe("1.0");
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
});
|
||||
|
||||
it("should handle empty extraParams", () => {
|
||||
const extraParams = {};
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const queryParams = { userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.searchParams.has("user_id")).toBe(false);
|
||||
});
|
||||
|
||||
it("should work when extraParams is undefined", () => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, onlyData: true });
|
||||
|
||||
const queryParams = { userId: "123" };
|
||||
const result = api.getUrl("/test", queryParams);
|
||||
|
||||
expect(result.searchParams.get("userId")!).toBe("123");
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?userId=123`);
|
||||
});
|
||||
|
||||
it("should work when queryParams is undefined", () => {
|
||||
const extraParams = { user_id: userId, version: "1.0" };
|
||||
const api = makeApiWithExtraParams(extraParams);
|
||||
|
||||
const result = api.getUrl("/test");
|
||||
|
||||
expect(result.searchParams.get("user_id")!).toBe(userId);
|
||||
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log query parameters", async () => {
|
||||
jest.useFakeTimers();
|
||||
const deferred = defer<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(deferred.promise);
|
||||
const responseResolvers = Promise.withResolvers<Response>();
|
||||
const fetchFn = jest.fn().mockReturnValue(responseResolvers.promise);
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
@@ -470,10 +690,11 @@ describe("FetchHttpApi", () => {
|
||||
prefix,
|
||||
fetchFn,
|
||||
logger: mockLogger,
|
||||
onlyData: true,
|
||||
});
|
||||
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);
|
||||
responseResolvers.resolve({ ok: true, status: 200, json: () => Promise.resolve("RESPONSE") } as Response);
|
||||
await prom;
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("fragment");
|
||||
expect(mockLogger.debug).not.toHaveBeenCalledWith("query");
|
||||
@@ -492,9 +713,7 @@ describe("FetchHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should not make multiple concurrent refresh token requests", async () => {
|
||||
const tokenInactiveError = new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Token is not active" }, 401);
|
||||
|
||||
const deferredTokenRefresh = defer<{ accessToken: string; refreshToken: string }>();
|
||||
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
@@ -518,12 +737,13 @@ describe("FetchHttpApi", () => {
|
||||
tokenRefreshFunction,
|
||||
accessToken: "ACCESS_TOKEN",
|
||||
refreshToken: "REFRESH_TOKEN",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10); // wait for requests to fire
|
||||
await sleep(0); // wait for requests to fire
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -547,4 +767,71 @@ describe("FetchHttpApi", () => {
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
|
||||
it("should use newly refreshed token if request starts mid-refresh", async () => {
|
||||
const deferredTokenRefresh = Promise.withResolvers<{ accessToken: string; refreshToken: string }>();
|
||||
const fetchFn = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: tokenInactiveError.httpStatus,
|
||||
async text() {
|
||||
return JSON.stringify(tokenInactiveError.data);
|
||||
},
|
||||
async json() {
|
||||
return tokenInactiveError.data;
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
const tokenRefreshFunction = jest.fn().mockReturnValue(deferredTokenRefresh.promise);
|
||||
|
||||
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
fetchFn,
|
||||
doNotAttemptTokenRefresh: false,
|
||||
tokenRefreshFunction,
|
||||
accessToken: "ACCESS_TOKEN",
|
||||
refreshToken: "REFRESH_TOKEN",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
const prom1 = api.authedRequest(Method.Get, "/path1");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
const prom2 = api.authedRequest(Method.Get, "/path2");
|
||||
await sleep(0); // wait for request to fire
|
||||
|
||||
deferredTokenRefresh.resolve({ accessToken: "NEW_ACCESS_TOKEN", refreshToken: "NEW_REFRESH_TOKEN" });
|
||||
fetchFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return "{}";
|
||||
},
|
||||
async json() {
|
||||
return {};
|
||||
},
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue("application/json"),
|
||||
},
|
||||
});
|
||||
|
||||
await prom1;
|
||||
await prom2;
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3); // 2 original calls + 1 retry
|
||||
expect(fetchFn.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(fetchFn.mock.calls[2][1]).toEqual(
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer NEW_ACCESS_TOKEN" }) }),
|
||||
);
|
||||
expect(tokenRefreshFunction).toHaveBeenCalledTimes(1);
|
||||
expect(api.opts.accessToken).toBe("NEW_ACCESS_TOKEN");
|
||||
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
function makeMockFetchFn(): MockedFunction<any> {
|
||||
return jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("MatrixHttpApi", () => {
|
||||
it("should fall back to `fetch` where xhr is unavailable", async () => {
|
||||
globalThis.XMLHttpRequest = undefined!;
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
await upload;
|
||||
expect(fetchFn).toHaveBeenCalled();
|
||||
@@ -70,7 +70,7 @@ describe("MatrixHttpApi", () => {
|
||||
|
||||
it("should prefer xhr where available", () => {
|
||||
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(xhr.open).toHaveBeenCalled();
|
||||
@@ -82,6 +82,7 @@ describe("MatrixHttpApi", () => {
|
||||
prefix,
|
||||
accessToken: "token",
|
||||
useAuthorizationHeader: false,
|
||||
onlyData: true,
|
||||
});
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(xhr.open).toHaveBeenCalledWith(
|
||||
@@ -96,6 +97,7 @@ describe("MatrixHttpApi", () => {
|
||||
baseUrl,
|
||||
prefix,
|
||||
accessToken: "token",
|
||||
onlyData: true,
|
||||
});
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
|
||||
@@ -103,7 +105,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should include filename by default", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File, { name: "name" });
|
||||
expect(xhr.open).toHaveBeenCalledWith(
|
||||
Method.Post,
|
||||
@@ -112,13 +114,13 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should allow not sending the filename", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File, { name: "name", includeFilename: false });
|
||||
expect(xhr.open).toHaveBeenCalledWith(Method.Post, baseUrl.toLowerCase() + "/_matrix/media/v3/upload");
|
||||
});
|
||||
|
||||
it("should abort xhr when the upload is aborted", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
api.cancelUpload(upload);
|
||||
expect(xhr.abort).toHaveBeenCalled();
|
||||
@@ -126,7 +128,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should timeout if no progress in 30s", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
jest.advanceTimersByTime(25000);
|
||||
// @ts-ignore
|
||||
@@ -138,7 +140,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should call progressHandler", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
const progressHandler = jest.fn();
|
||||
upload = api.uploadContent({} as File, { progressHandler });
|
||||
const progressEvent = new Event("progress") as ProgressEvent;
|
||||
@@ -154,7 +156,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should error when no response body", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
|
||||
xhr.readyState = DONE;
|
||||
@@ -167,7 +169,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should error on a 400-code", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
|
||||
xhr.readyState = DONE;
|
||||
@@ -184,7 +186,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should return response on successful upload", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
|
||||
xhr.readyState = DONE;
|
||||
@@ -198,14 +200,14 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should abort xhr when calling `cancelUpload`", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(api.cancelUpload(upload)).toBeTruthy();
|
||||
expect(xhr.abort).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false when `cancelUpload` is called but unsuccessful", async () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
|
||||
xhr.readyState = DONE;
|
||||
@@ -220,7 +222,7 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should return active uploads in `getCurrentUploads`", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, onlyData: true });
|
||||
upload = api.uploadContent({} as File);
|
||||
expect(api.getCurrentUploads().find((u) => u.promise === upload)).toBeTruthy();
|
||||
api.cancelUpload(upload);
|
||||
@@ -228,7 +230,12 @@ describe("MatrixHttpApi", () => {
|
||||
});
|
||||
|
||||
it("should return expected object from `getContentUri`", () => {
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, accessToken: "token" });
|
||||
const api = new MatrixHttpApi(new TypedEventEmitter<any, any>(), {
|
||||
baseUrl,
|
||||
prefix,
|
||||
accessToken: "token",
|
||||
onlyData: true,
|
||||
});
|
||||
expect(api.getContentUri()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
|
||||
import loglevel from "loglevel";
|
||||
|
||||
import { logger } from "../../src/logger.ts";
|
||||
import { DebugLogger, logger } from "../../src/logger.ts";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
@@ -49,3 +49,42 @@ describe("logger", () => {
|
||||
expect(console.debug).toHaveBeenCalledWith("[prefix1][prefix2]", "test2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DebugLogger", () => {
|
||||
it("should handle empty log messages", () => {
|
||||
const mockTarget = jest.fn();
|
||||
const logger = new DebugLogger(mockTarget as any);
|
||||
logger.info();
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith("[INFO] ");
|
||||
});
|
||||
|
||||
it("should handle logging an Error", () => {
|
||||
const mockTarget = jest.fn();
|
||||
const logger = new DebugLogger(mockTarget as any);
|
||||
|
||||
// If there is a stack and a message, we use the stack.
|
||||
const error = new Error("I am an error");
|
||||
logger.error(error);
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith(expect.stringMatching(/^\[ERROR\] Error: I am an error\n\s*at/));
|
||||
|
||||
mockTarget.mockClear();
|
||||
|
||||
// If there is only a message, we use that.
|
||||
error.stack = undefined;
|
||||
logger.error(error);
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith("[ERROR] I am an error");
|
||||
});
|
||||
|
||||
it("should handle logging an object", () => {
|
||||
const mockTarget = jest.fn();
|
||||
const logger = new DebugLogger(mockTarget as any);
|
||||
|
||||
const obj = { a: 1 };
|
||||
logger.warn(obj);
|
||||
expect(mockTarget).toHaveBeenCalledTimes(1);
|
||||
expect(mockTarget).toHaveBeenCalledWith("[WARN] %O", obj);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
PolicyRecommendation,
|
||||
PolicyScope,
|
||||
} from "../../src/models/invites-ignorer";
|
||||
import { defer, type QueryDict } from "../../src/utils";
|
||||
import { type QueryDict } from "../../src/utils";
|
||||
import { type SyncState } from "../../src/sync";
|
||||
import * as featureUtils from "../../src/feature";
|
||||
import { StubStore } from "../../src/store/stub";
|
||||
@@ -1997,8 +1997,8 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
|
||||
it("should cancel an event which is encrypting", async () => {
|
||||
const encryptEventDefer = defer();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
|
||||
const encryptEventResolvers = Promise.withResolvers<void>();
|
||||
mockCrypto.encryptEvent.mockReturnValue(encryptEventResolvers.promise);
|
||||
|
||||
const statusPromise = testUtils.emitPromise(event, "Event.status");
|
||||
// @ts-ignore protected method access
|
||||
@@ -2009,7 +2009,7 @@ describe("MatrixClient", function () {
|
||||
assertCancelled();
|
||||
|
||||
// now let the encryption complete, and check that the message is not sent.
|
||||
encryptEventDefer.resolve();
|
||||
encryptEventResolvers.resolve();
|
||||
await encryptAndSendPromise;
|
||||
assertCancelled();
|
||||
});
|
||||
@@ -3597,24 +3597,6 @@ describe("MatrixClient", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthIssuer", () => {
|
||||
it("should use unstable prefix", async () => {
|
||||
httpLookups = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/auth_issuer`,
|
||||
data: {
|
||||
issuer: "https://issuer/",
|
||||
},
|
||||
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
|
||||
},
|
||||
];
|
||||
|
||||
await expect(client.getAuthIssuer()).resolves.toEqual({ issuer: "https://issuer/" });
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthMetadata", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mock } from "jest-mock";
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimeline,
|
||||
EventType,
|
||||
type IRoomTimelineData,
|
||||
MatrixClient,
|
||||
type MatrixEvent,
|
||||
RoomEvent,
|
||||
} from "../../../src";
|
||||
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
|
||||
import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("MatrixRTCSessionManager", () => {
|
||||
let client: MatrixClient;
|
||||
@@ -57,137 +48,102 @@ describe("MatrixRTCSessionManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("Doesn't fire event if unrelated sessions starts", () => {
|
||||
const onStarted = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
}
|
||||
});
|
||||
|
||||
it("Fires event when session ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom(membershipTemplate);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
(room1.getLiveTimeline as Mock).mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(makeMockRoomState([{}], room1.roomId)),
|
||||
});
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("")[0];
|
||||
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
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;
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
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"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
it("Fires correctly with for with custom sessionDescription", () => {
|
||||
const onStarted = jest.fn();
|
||||
const onEnded = jest.fn();
|
||||
// create a session manager with a custom session description
|
||||
const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" });
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
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"),
|
||||
isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure),
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
// manually start the session manager (its not the default one started by the client)
|
||||
sessionManager.start();
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
try {
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const room1 = makeMockRoom([membershipTemplate]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
client.emit(ClientEvent.Room, room2);
|
||||
expect(onStarted).toHaveBeenCalled();
|
||||
onStarted.mockClear();
|
||||
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
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"),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail
|
||||
getId: jest.fn().mockReturnValue("event_id"),
|
||||
sender: {
|
||||
userId: "@mock:user.example",
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room2);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
onEnded.mockClear();
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null);
|
||||
expect(onEnded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
|
||||
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
}
|
||||
});
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
it("Doesn't fire event if unrelated sessions ends", () => {
|
||||
const onEnded = jest.fn();
|
||||
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
|
||||
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]);
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room1);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
client.emit(ClientEvent.Room, room1);
|
||||
|
||||
mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]);
|
||||
|
||||
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
|
||||
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
|
||||
|
||||
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||
*/
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -27,43 +24,49 @@ import {
|
||||
type LivekitFocusActive,
|
||||
type SessionMembershipData,
|
||||
} from "../../../src/matrixrtc";
|
||||
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
|
||||
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
||||
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementation(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
/**
|
||||
* Create a promise that will resolve once a mocked method is called.
|
||||
* @param method The method to wait for.
|
||||
* @param returnVal Provide an optional value that the mocked method should return. (use Promise.resolve(val) or Promise.reject(err))
|
||||
* @returns The promise that resolves once the method is called.
|
||||
*/
|
||||
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
method.mockImplementation(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** See waitForMockCall */
|
||||
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
method.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return returnVal ?? Promise.resolve();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function createAsyncHandle(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = defer();
|
||||
/**
|
||||
* A handle to control when in the test flow the provided method resolves (or gets rejected).
|
||||
* @param method The method to control the resolve timing.
|
||||
* @returns
|
||||
*/
|
||||
function createAsyncHandle<T>(method: MockedFunction<any>) {
|
||||
const { reject, resolve, promise } = Promise.withResolvers<T>();
|
||||
method.mockImplementation(() => promise);
|
||||
return { reject, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
|
||||
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
|
||||
*/
|
||||
const callSession = { id: "", application: "m.call" };
|
||||
|
||||
describe.each([
|
||||
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
|
||||
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||
])("$description", ({ TestMembershipManager }) => {
|
||||
describe("MembershipManager", () => {
|
||||
let client: MockClient;
|
||||
let room: Room;
|
||||
const focusActive: LivekitFocusActive = {
|
||||
@@ -80,7 +83,7 @@ describe.each([
|
||||
// Default to fake timers.
|
||||
jest.useFakeTimers();
|
||||
client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
room = makeMockRoom(membershipTemplate);
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
// Provide a default mock that is like the default "non error" server behaviour.
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||
@@ -94,12 +97,12 @@ describe.each([
|
||||
|
||||
describe("isActivated()", () => {
|
||||
it("defaults to false", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(manager.isActivated()).toEqual(false);
|
||||
});
|
||||
|
||||
it("returns true after join()", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([]);
|
||||
expect(manager.isActivated()).toEqual(true);
|
||||
});
|
||||
@@ -110,13 +113,13 @@ describe.each([
|
||||
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||
// Spys/Mocks
|
||||
|
||||
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
|
||||
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
|
||||
|
||||
// Test
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
memberManager.join([focus], focusActive);
|
||||
// expects
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
@@ -129,7 +132,7 @@ describe.each([
|
||||
focus_active: focusActive,
|
||||
scope: "m.room",
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
updateDelayedEventHandle.resolve?.();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
@@ -137,13 +140,13 @@ describe.each([
|
||||
{ delay: 8000 },
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reschedules delayed leave event if sending state cancels it", async () => {
|
||||
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||
const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession);
|
||||
const waitForSendState = waitForMockCall(client.sendStateEvent);
|
||||
const waitForUpdateDelaye = waitForMockCallOnce(
|
||||
client._unstable_updateDelayedEvent,
|
||||
@@ -188,7 +191,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
|
||||
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`;
|
||||
// preparing the delayed disconnect should handle ratelimiting
|
||||
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
|
||||
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
|
||||
@@ -212,13 +215,14 @@ describe.each([
|
||||
return Promise.reject(error);
|
||||
});
|
||||
});
|
||||
const manager = new TestMembershipManager(
|
||||
const manager = new MembershipManager(
|
||||
{
|
||||
membershipServerSideExpiryTimeout: 9000,
|
||||
delayedLeaveEventDelayMs: 9000,
|
||||
},
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
@@ -275,7 +279,7 @@ describe.each([
|
||||
describe("delayed leave event", () => {
|
||||
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(
|
||||
new UnsupportedDelayedEventsEndpointError(
|
||||
@@ -287,18 +291,19 @@ describe.each([
|
||||
});
|
||||
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("uses membershipServerSideExpiryTimeout from config", () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipServerSideExpiryTimeout: 123456 },
|
||||
it("uses delayedLeaveEventDelayMs from config", () => {
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventDelayMs: 123456 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||
@@ -306,17 +311,57 @@ describe.each([
|
||||
{ delay: 123456 },
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses membershipExpiryTimeout from config", async () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipExpiryTimeout: 1234567 },
|
||||
it("rejoins if delayed event is not found (404)", async () => {
|
||||
const RESTART_DELAY = 15000;
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventRestartMs: RESTART_DELAY },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
// Join with the membership manager
|
||||
manager.join([focus], focusActive);
|
||||
expect(manager.status).toBe(Status.Connecting);
|
||||
// Let the scheduler run one iteration so that we can send the join state event
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
// Now that we are connected, we set up the mocks.
|
||||
// We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave:
|
||||
// - We wait until the delayed event gets sent and then mock its response to be "not found."
|
||||
// - We enforce a race condition between the sync that informs us that our call membership state event was set to "left"
|
||||
// and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent.
|
||||
// - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left."
|
||||
// (onRTCSessionMemberUpdate)
|
||||
// - Only then do we resolve the sending of the delayed event.
|
||||
// - We test that the manager acknowledges the leave and sends a new membership state event.
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await jest.advanceTimersByTimeAsync(RESTART_DELAY);
|
||||
// first simulate the sync, then resolve sending the delayed event.
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
// Let the scheduler run one iteration so that the new join gets sent
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses membershipEventExpiryMs from config", async () => {
|
||||
const manager = new MembershipManager(
|
||||
{ membershipEventExpiryMs: 1234567 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
|
||||
manager.join([focus], focusActive);
|
||||
@@ -336,12 +381,12 @@ describe.each([
|
||||
type: "livekit",
|
||||
},
|
||||
},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if join called when already joined", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -353,7 +398,7 @@ describe.each([
|
||||
describe("leave()", () => {
|
||||
// TODO add rate limit cases.
|
||||
it("resolves delayed leave event when leave is called", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await manager.leave();
|
||||
@@ -361,7 +406,7 @@ describe.each([
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||
@@ -372,12 +417,11 @@ describe.each([
|
||||
room.roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
{},
|
||||
"_@alice:example.org_AAAAAAA",
|
||||
"_@alice:example.org_AAAAAAA_m.call",
|
||||
);
|
||||
});
|
||||
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
|
||||
it("does nothing if not joined !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("does nothing if not joined", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(async () => await manager.leave()).not.toThrow();
|
||||
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -387,7 +431,7 @@ describe.each([
|
||||
describe("getsActiveFocus", () => {
|
||||
it("gets the correct active focus with oldest_membership", () => {
|
||||
const getOldestMembership = jest.fn();
|
||||
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
|
||||
const manager = new MembershipManager({}, room, client, getOldestMembership, callSession);
|
||||
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -403,11 +447,11 @@ describe.each([
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
device_id: client.getDeviceId(),
|
||||
user_id: client.getUserId()!,
|
||||
device_id: client.getDeviceId()!,
|
||||
created_ts: 1000,
|
||||
},
|
||||
room.roomId,
|
||||
client.getUserId()!,
|
||||
),
|
||||
);
|
||||
expect(manager.getActiveFocus()).toStrictEqual(focus);
|
||||
@@ -422,7 +466,7 @@ describe.each([
|
||||
});
|
||||
|
||||
it("does not provide focus if the selection method is unknown", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||
expect(manager.getActiveFocus()).toBe(undefined);
|
||||
});
|
||||
@@ -430,7 +474,7 @@ describe.each([
|
||||
|
||||
describe("onRTCSessionMemberUpdate()", () => {
|
||||
it("does nothing if not joined", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
@@ -438,7 +482,7 @@ describe.each([
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does nothing if own membership still present", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||
@@ -449,7 +493,10 @@ describe.each([
|
||||
|
||||
await manager.onRTCSessionMemberUpdate([
|
||||
mockCallMembership(membershipTemplate, room.roomId),
|
||||
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
|
||||
mockCallMembership(
|
||||
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
|
||||
room.roomId,
|
||||
),
|
||||
]);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
@@ -459,7 +506,7 @@ describe.each([
|
||||
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("recreates membership if it is missing", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
@@ -475,16 +522,43 @@ describe.each([
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates the UpdateExpiry entry in the action scheduler", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||
(client.sendStateEvent as Mock).mockClear();
|
||||
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
|
||||
new MatrixError({ errcode: "M_NOT_FOUND" }),
|
||||
);
|
||||
|
||||
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||
resolve({ delay_id: "id" });
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||
|
||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||
expect(manager.status).toBe(Status.Connected);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Not sure about this name
|
||||
describe("background timers", () => {
|
||||
it("sends only one keep-alive for delayed leave event per `membershipKeepAlivePeriod`", async () => {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipKeepAlivePeriod: 10_000, membershipServerSideExpiryTimeout: 30_000 },
|
||||
it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => {
|
||||
const manager = new MembershipManager(
|
||||
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
@@ -506,16 +580,17 @@ describe.each([
|
||||
}
|
||||
});
|
||||
|
||||
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
|
||||
// because the expires logic was removed for the legacy call manager.
|
||||
// Delayed events should replace it entirely but before they have wide adoption
|
||||
// the expiration logic still makes sense.
|
||||
// TODO: Add git commit when we removed it.
|
||||
async function testExpires(expire: number, headroom?: number) {
|
||||
const manager = new TestMembershipManager(
|
||||
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
|
||||
const manager = new MembershipManager(
|
||||
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
{ id: "", application: "m.call" },
|
||||
);
|
||||
manager.join([focus], focusActive);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
@@ -529,23 +604,23 @@ describe.each([
|
||||
expect(sentMembership.expires).toBe(expire * i);
|
||||
}
|
||||
}
|
||||
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||
it("extends `expires` when call still active", async () => {
|
||||
await testExpires(10_000);
|
||||
});
|
||||
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
|
||||
it("extends `expires` using headroom configuration", async () => {
|
||||
await testExpires(10_000, 1_000);
|
||||
});
|
||||
});
|
||||
describe("status updates", () => {
|
||||
it("starts 'Disconnected' !FailsForLegacy", () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("starts 'Disconnected'", () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
});
|
||||
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
|
||||
it("emits 'Connection' and 'Connected' after join", async () => {
|
||||
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
|
||||
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
expect(manager.status).toBe(Status.Disconnected);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
@@ -558,8 +633,8 @@ describe.each([
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
|
||||
});
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
const connectEmit = jest.fn();
|
||||
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
|
||||
manager.join([focus], focusActive);
|
||||
@@ -575,7 +650,7 @@ describe.each([
|
||||
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -592,9 +667,8 @@ describe.each([
|
||||
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
|
||||
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
|
||||
it("abandons retry loop and sends new own membership if not present anymore", async () => {
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
429,
|
||||
@@ -603,7 +677,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||
// RateLimit error.
|
||||
manager.join([focus], focusActive);
|
||||
@@ -620,11 +694,10 @@ describe.each([
|
||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
|
||||
it("abandons retry loop if leave() was called before sending state event", async () => {
|
||||
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
handle.reject?.(
|
||||
new MatrixError(
|
||||
@@ -649,7 +722,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
describe("retries sending update delayed leave event restart", () => {
|
||||
it("resends the initial check delayed update event !FailsForLegacy", async () => {
|
||||
it("resends the initial check delayed update event", async () => {
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||
@@ -659,7 +732,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive);
|
||||
|
||||
// Hit rate limit
|
||||
@@ -680,8 +753,8 @@ describe.each([
|
||||
});
|
||||
});
|
||||
describe("unrecoverable errors", () => {
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries for initial delayed event creation", async () => {
|
||||
const delayEventSendError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
@@ -692,7 +765,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "2" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, delayEventSendError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -700,8 +773,8 @@ describe.each([
|
||||
}
|
||||
expect(delayEventSendError).toHaveBeenCalled();
|
||||
});
|
||||
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
|
||||
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
|
||||
it("throws, when reaching maximum number of retries", async () => {
|
||||
const delayEventRestartError = jest.fn();
|
||||
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||
new MatrixError(
|
||||
@@ -712,7 +785,7 @@ describe.each([
|
||||
new Headers({ "Retry-After": "1" }),
|
||||
),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, delayEventRestartError);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -720,23 +793,24 @@ describe.each([
|
||||
}
|
||||
expect(delayEventRestartError).toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
|
||||
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await waitForMockCall(client.sendStateEvent);
|
||||
expect(unrecoverableError).not.toHaveBeenCalledWith();
|
||||
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||
});
|
||||
it("retries before failing in case its a network error !FailsForLegacy", async () => {
|
||||
it("retries before failing in case its a network error", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
|
||||
const manager = new TestMembershipManager(
|
||||
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
const manager = new MembershipManager(
|
||||
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
|
||||
room,
|
||||
client,
|
||||
() => undefined,
|
||||
callSession,
|
||||
);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
for (let retries = 0; retries < 7; retries++) {
|
||||
@@ -749,12 +823,12 @@ describe.each([
|
||||
);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
|
||||
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => {
|
||||
const unrecoverableError = jest.fn();
|
||||
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
|
||||
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
|
||||
);
|
||||
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||
const manager = new MembershipManager({}, room, client, () => undefined, callSession);
|
||||
manager.join([focus], focusActive, unrecoverableError);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
@@ -763,3 +837,19 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Should prefix log with MembershipManager used", () => {
|
||||
const client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||
const room = makeMockRoom([membershipTemplate]);
|
||||
|
||||
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger);
|
||||
|
||||
const spy = jest.spyOn(console, "error");
|
||||
// Double join
|
||||
membershipManager.join([]);
|
||||
membershipManager.join([]);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const logline: string = spy.mock.calls[0][0];
|
||||
expect(logline.startsWith("[MembershipManager]")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2025 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 { OutdatedKeyFilter } from "../../../src/matrixrtc/utils.ts";
|
||||
import { type InboundEncryptionSession } from "../../../src/matrixrtc";
|
||||
|
||||
describe("OutdatedKeyFilter Test", () => {
|
||||
it("Should buffer and disambiguate keys by timestamp", () => {
|
||||
const filter = new OutdatedKeyFilter();
|
||||
|
||||
const aKey = fakeInboundSessionWithTimestamp(1000);
|
||||
const olderKey = fakeInboundSessionWithTimestamp(300);
|
||||
// Simulate receiving out of order keys
|
||||
|
||||
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
|
||||
// Then we receive the most recent key out of order
|
||||
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
|
||||
// this key is older and should be ignored even if received after
|
||||
expect(isOutdated).toBe(true);
|
||||
});
|
||||
|
||||
function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession {
|
||||
return {
|
||||
keyIndex: 0,
|
||||
creationTS: ts,
|
||||
participantId: "@alice:localhost|ABCDE",
|
||||
key: new Uint8Array(16),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,896 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
|
||||
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
|
||||
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
|
||||
import { decodeBase64, TypedEventEmitter } from "../../../src";
|
||||
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
import { logger, type Logger } from "../../../src/logger.ts";
|
||||
import { getParticipantId } from "../../../src/matrixrtc/utils.ts";
|
||||
|
||||
describe("RTCEncryptionManager", () => {
|
||||
// The manager being tested
|
||||
let encryptionManager: RTCEncryptionManager;
|
||||
let getMembershipMock: jest.Mock;
|
||||
let mockTransport: Mocked<ToDeviceKeyTransport>;
|
||||
let statistics: Statistics;
|
||||
let onEncryptionKeysChanged: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
getMembershipMock = jest.fn().mockReturnValue([]);
|
||||
onEncryptionKeysChanged = jest.fn();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it("should start and stop the transport properly", () => {
|
||||
encryptionManager.join(undefined);
|
||||
|
||||
expect(mockTransport.start).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.on).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.on).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
|
||||
encryptionManager.leave();
|
||||
expect(mockTransport.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.off).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
|
||||
});
|
||||
|
||||
describe("Sharing Keys", () => {
|
||||
it("Set up my key asap even if no key distribution is needed", () => {
|
||||
getMembershipMock.mockReturnValue([]);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
// After join it is too early, key might be lost as no one is listening yet
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should distribute keys to members on join", async () => {
|
||||
jest.useFakeTimers();
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
|
||||
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
membershipTs: 1000,
|
||||
},
|
||||
],
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
// The key should have been rolled out immediately
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
|
||||
mockTransport.sendKey.mockClear();
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// There are no membership change but the callMembership ts has changed (reset?)
|
||||
// Resend the key
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// Re send the same key to that user
|
||||
0,
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE",
|
||||
membershipTs: 2000,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("Should not rotate key when a user join within the rotation grace period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
const gracePeriod = 15_000; // 15 seconds
|
||||
// initial rollout
|
||||
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
// Carl joins, within the grace period
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
await jest.advanceTimersByTimeAsync(gracePeriod / 2);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should not have incremented the key index
|
||||
0,
|
||||
// And send it to the newly joined only
|
||||
[{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1000 }],
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
// Test an edge case where the use key delay is higher than the grace period.
|
||||
// This means that no matter what, the key once rolled out will be too old to be re-used for the new member that
|
||||
// joined within the grace period.
|
||||
// So we expect another rotation to happen in all cases where a new member joins.
|
||||
it("test grace period lower than delay period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
const gracePeriod = 3_000; // 3 seconds
|
||||
const useKeyDelay = gracePeriod + 2_000; // 5 seconds
|
||||
// initial rollout
|
||||
encryptionManager.join({
|
||||
useKeyDelay,
|
||||
keyRotationGracePeriodMs: gracePeriod,
|
||||
});
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
// The existing members have been talking for 5mn
|
||||
await jest.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
|
||||
// A new member joins, that should trigger a key rotation.
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
// A new member joins, within the grace period, but under the delay period
|
||||
members.push(aCallMembership("@david:example.org", "DAVDEVICE"));
|
||||
await jest.advanceTimersByTimeAsync((useKeyDelay - gracePeriod) / 2);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
// Wait past the delay period
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
// Even though the new member joined within the grace period, the key should be rotated because once the delay period has passed
|
||||
// also the grace period is exceeded/the key is too old to be reshared.
|
||||
|
||||
// CARLDEVICE should have received a key with index 1 and another one with index 2
|
||||
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 1, "@carl:example.org", "CARLDEVICE");
|
||||
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 2, "@carl:example.org", "CARLDEVICE");
|
||||
// Of course, should not have received the first key
|
||||
expectKeyAtIndexNotToHaveBeenSentTo(mockTransport, 0, "@carl:example.org", "CARLDEVICE");
|
||||
|
||||
// DAVDEVICE should only have received a key with index 2
|
||||
expectKeyAtIndexToHaveBeenSentTo(mockTransport, 2, "@david:example.org", "DAVDEVICE");
|
||||
expectKeyAtIndexNotToHaveBeenSentTo(mockTransport, 1, "@david:example.org", "DAVDEVICE");
|
||||
});
|
||||
|
||||
it("Should rotate key when a user join past the rotation grace period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
const gracePeriod = 15_000; // 15 seconds
|
||||
// initial rollout
|
||||
encryptionManager.join({ keyRotationGracePeriodMs: gracePeriod });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(gracePeriod + 1000);
|
||||
members.push(aCallMembership("@carl:example.org", "CARLDEVICE"));
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should have incremented the key index
|
||||
1,
|
||||
// And send it to everyone
|
||||
[
|
||||
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE" }),
|
||||
expect.objectContaining({ userId: "@bob:example.org", deviceId: "BOBDEVICE2" }),
|
||||
expect.objectContaining({ userId: "@carl:example.org", deviceId: "CARLDEVICE" }),
|
||||
],
|
||||
);
|
||||
|
||||
// Wait for useKeyDelay to pass
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not rotate key when several users join within the rotation grace period", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
const newJoiners = [
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
aCallMembership("@dave:example.org", "DAVEDEVICE"),
|
||||
aCallMembership("@eve:example.org", "EVEDEVICE"),
|
||||
aCallMembership("@frank:example.org", "FRANKDEVICE"),
|
||||
aCallMembership("@george:example.org", "GEORGEDEVICE"),
|
||||
];
|
||||
|
||||
for (const newJoiner of newJoiners) {
|
||||
members.push(newJoiner);
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
await jest.advanceTimersByTimeAsync(1_000);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
}
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(newJoiners.length);
|
||||
|
||||
for (const newJoiner of newJoiners) {
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should not have incremented the key index
|
||||
0,
|
||||
// And send it to the new joiners only
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ userId: newJoiner.sender, deviceId: newJoiner.deviceId }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should not resend keys when no changes", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// initial rollout
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
mockTransport.sendKey.mockClear();
|
||||
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(50);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should rotate key when a user leaves and delay the rollout", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key
|
||||
0,
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
// initial rollout
|
||||
expect(mockTransport.sendKey).toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
const updatedMembers = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(updatedMembers);
|
||||
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(200);
|
||||
// The is rotated but not rolled out yet to give time for the key to be sent
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It should have incremented the key index
|
||||
1,
|
||||
// And send it to the updated members
|
||||
updatedMembers.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// now should be rolled out
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
|
||||
});
|
||||
|
||||
it("Should not distribute keys if encryption is disabled", async () => {
|
||||
jest.useFakeTimers();
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join({ manageMediaKeys: false });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalled();
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Receiving Keys", () => {
|
||||
beforeEach(() => {
|
||||
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
mockTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
mockTransport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not accept keys when manageMediaKeys is disabled", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join({ manageMediaKeys: false });
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept keys from transport", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
"BBBBBBBBBBB",
|
||||
4 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"CCCCCCCCCC",
|
||||
8 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(4);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("AAAAAAAAAAA"),
|
||||
0,
|
||||
"@bob:example.org:BOBDEVICE",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
4,
|
||||
"@bob:example.org:BOBDEVICE2",
|
||||
);
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
decodeBase64("CCCCCCCCCC"),
|
||||
8,
|
||||
"@carl:example.org:CARLDEVICE",
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
|
||||
});
|
||||
|
||||
it("Should support quick re-joiner if keys received out of order", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Simulate Carl leaving then joining back, and key received out of order
|
||||
const initialKey0TimeStamp = 1000;
|
||||
const newKey0TimeStamp = 2000;
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
newKey0TimeStamp,
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carol:example.org",
|
||||
"CAROLDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
initialKey0TimeStamp,
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(20);
|
||||
|
||||
// The latest key used for carol should be the one with the latest timestamp
|
||||
|
||||
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
|
||||
decodeBase64("BBBBBBBBBBB"),
|
||||
0,
|
||||
"@carol:example.org:CAROLDEVICE",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should store keys for later retrieval", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"BBBBBBBBBBB",
|
||||
0 /* KeyId */,
|
||||
1000,
|
||||
);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@carl:example.org",
|
||||
"CARLDEVICE",
|
||||
"CCCCCCCCCCC",
|
||||
5 /* KeyId */,
|
||||
1000,
|
||||
);
|
||||
|
||||
mockTransport.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE2",
|
||||
"DDDDDDDDDDD",
|
||||
0 /* KeyId */,
|
||||
1000,
|
||||
);
|
||||
|
||||
const knownKeys = encryptionManager.getEncryptionKeys();
|
||||
|
||||
// My own key should be there
|
||||
const myRing = knownKeys.get(getParticipantId("@alice:example.org", "DEVICE01"));
|
||||
expect(myRing).toBeDefined();
|
||||
expect(myRing).toHaveLength(1);
|
||||
expect(myRing![0]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 0,
|
||||
key: expect.any(Uint8Array),
|
||||
}),
|
||||
);
|
||||
|
||||
const carlRing = knownKeys.get(getParticipantId("@carl:example.org", "CARLDEVICE"));
|
||||
expect(carlRing).toBeDefined();
|
||||
expect(carlRing).toHaveLength(2);
|
||||
expect(carlRing![0]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 0,
|
||||
key: decodeBase64("BBBBBBBBBBB"),
|
||||
}),
|
||||
);
|
||||
expect(carlRing![1]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 5,
|
||||
key: decodeBase64("CCCCCCCCCCC"),
|
||||
}),
|
||||
);
|
||||
|
||||
const bobRing = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE2"));
|
||||
expect(bobRing).toBeDefined();
|
||||
expect(bobRing).toHaveLength(1);
|
||||
expect(bobRing![0]).toMatchObject(
|
||||
expect.objectContaining({
|
||||
keyIndex: 0,
|
||||
key: decodeBase64("DDDDDDDDDDD"),
|
||||
}),
|
||||
);
|
||||
|
||||
const bob1Ring = knownKeys.get(getParticipantId("@bob:example.org", "BOBDEVICE"));
|
||||
expect(bob1Ring).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("Should only rotate once again if several membership changes during a rollout", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
let members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// The initial rollout
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
0,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
onEncryptionKeysChanged.mockClear();
|
||||
|
||||
// Trigger a key rotation with a leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// This should start a new key rollout
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now simulate a new leaver
|
||||
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// The key `1` rollout is in progress
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// And another one ( plus a joiner)
|
||||
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
|
||||
getMembershipMock.mockReturnValue(lastMembership);
|
||||
// The key `1` rollout is still in progress
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Let all rollouts finish
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
// There should 2 rollout. The `1` rollout, then just one additional one
|
||||
// that has "buffered" the 2 membership changes with leavers
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
1,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
|
||||
expect.any(Uint8Array<ArrayBufferLike>),
|
||||
2,
|
||||
"@alice:example.org:DEVICE01",
|
||||
);
|
||||
|
||||
// Key `2` should only be distributed to the last membership
|
||||
expect(mockTransport.sendKey).toHaveBeenLastCalledWith(
|
||||
expect.any(String),
|
||||
2,
|
||||
// And send only to the last membership
|
||||
[
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BOBDEVICE3",
|
||||
membershipTs: 1000,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("Should re-distribute key on transport switch", async () => {
|
||||
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockToDeviceTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: toDeviceEmitter.on.bind(toDeviceEmitter),
|
||||
off: toDeviceEmitter.off.bind(toDeviceEmitter),
|
||||
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<ToDeviceKeyTransport>;
|
||||
|
||||
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
|
||||
const mockRoomTransport = {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
sendKey: jest.fn().mockResolvedValue(undefined),
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
setParentLogger: jest.fn(),
|
||||
} as unknown as Mocked<RoomKeyTransport>;
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
|
||||
encryptionManager = new RTCEncryptionManager(
|
||||
"@alice:example.org",
|
||||
"DEVICE01",
|
||||
getMembershipMock,
|
||||
transport,
|
||||
statistics,
|
||||
onEncryptionKeysChanged,
|
||||
);
|
||||
|
||||
const members = [
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE"),
|
||||
aCallMembership("@bob:example.org", "BOBDEVICE2"),
|
||||
aCallMembership("@carl:example.org", "CARLDEVICE"),
|
||||
];
|
||||
getMembershipMock.mockReturnValue(members);
|
||||
|
||||
// Let's join
|
||||
encryptionManager.join(undefined);
|
||||
encryptionManager.onMembershipsUpdate();
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Should have sent the key to the toDevice transport
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate receiving a key by room transport
|
||||
roomEmitter.emit(
|
||||
KeyTransportEvents.ReceivedKeys,
|
||||
"@bob:example.org",
|
||||
"BOBDEVICE",
|
||||
"AAAAAAAAAAA",
|
||||
0 /* KeyId */,
|
||||
0 /* Timestamp */,
|
||||
);
|
||||
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
// The key should have been re-distributed to the room transport
|
||||
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
|
||||
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
// It is the first key re-distributed
|
||||
0,
|
||||
// to all the members
|
||||
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
|
||||
);
|
||||
});
|
||||
|
||||
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
|
||||
return mockCallMembership(
|
||||
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
|
||||
"!room:id",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function expectKeyAtIndexToHaveBeenSentTo(
|
||||
mockTransport: Mocked<ToDeviceKeyTransport>,
|
||||
index: number,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) {
|
||||
expect(mockTransport.sendKey).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
index,
|
||||
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
|
||||
);
|
||||
}
|
||||
function expectKeyAtIndexNotToHaveBeenSentTo(
|
||||
mockTransport: Mocked<ToDeviceKeyTransport>,
|
||||
index: number,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
) {
|
||||
expect(mockTransport.sendKey).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
index,
|
||||
expect.arrayContaining([expect.objectContaining({ userId, deviceId })]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
|
||||
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsEvents,
|
||||
mockClientMethodsUser,
|
||||
} from "../../test-utils/client.ts";
|
||||
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
|
||||
|
||||
describe("RoomAndToDeviceTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: RoomAndToDeviceTransport;
|
||||
let mockRoom: Room;
|
||||
let sendEventMock: jest.Mock;
|
||||
let roomKeyTransport: RoomKeyTransport;
|
||||
let toDeviceKeyTransport: ToDeviceKeyTransport;
|
||||
let toDeviceSendKeySpy: jest.SpyInstance;
|
||||
let roomSendKeySpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
sendEventMock = jest.fn();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn(),
|
||||
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
|
||||
...mockClientMethodsEvents(),
|
||||
...mockClientMethodsUser("@alice:example.org"),
|
||||
sendEvent: sendEventMock,
|
||||
});
|
||||
mockRoom = makeMockRoom([]);
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
getChild: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
mockLogger.getChild.mockReturnValue(mockLogger);
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
|
||||
toDeviceKeyTransport = new ToDeviceKeyTransport(
|
||||
"@alice:example.org",
|
||||
"MYDEVICE",
|
||||
mockRoom.roomId,
|
||||
mockClient,
|
||||
statistics,
|
||||
);
|
||||
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
|
||||
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
|
||||
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
|
||||
});
|
||||
|
||||
it("should enable to device transport when starting", () => {
|
||||
transport.start();
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
it("only sends to device keys when sending a key", async () => {
|
||||
transport.start();
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport when receiving a room key", async () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
mockRoom.emit(
|
||||
RoomEvent.Timeline,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
await transport.sendKey("1235", 0, [
|
||||
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
|
||||
]);
|
||||
expect(sendEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
});
|
||||
|
||||
it("enables room transport and disables to device transport on widget driver error", async () => {
|
||||
mockClient.encryptAndSendToDevice.mockRejectedValue({
|
||||
message:
|
||||
"unknown variant `send_to_device`, expected one of `supported_api_versions`, `content_loaded`, `get_openid`, `org.matrix.msc2876.read_events`, `send_event`, `org.matrix.msc4157.update_delayed_event` at line 1 column 22",
|
||||
});
|
||||
|
||||
transport.start();
|
||||
const membership: ParticipantDeviceInfo = {
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "ALICEDEVICE",
|
||||
membershipTs: 1234,
|
||||
};
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
// We start with toDevice transport enabled
|
||||
expect(transport.enabled.room).toBeFalsy();
|
||||
expect(transport.enabled.toDevice).toBeTruthy();
|
||||
|
||||
await transport.sendKey("1235", 0, [membership]);
|
||||
|
||||
// We switched transport, now room transport is enabled
|
||||
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
|
||||
expect(transport.enabled.room).toBeTruthy();
|
||||
expect(transport.enabled.toDevice).toBeFalsy();
|
||||
|
||||
// sanity check that we called the failang to device send key.
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(toDeviceKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
// We re-sent the key via the room transport
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledWith("1235", 0, [membership]);
|
||||
expect(roomKeyTransport.sendKey).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockClient.encryptAndSendToDevice.mockRestore();
|
||||
});
|
||||
|
||||
it("does log that it did nothing when disabled", () => {
|
||||
transport.start();
|
||||
const onNewKeyFromTransport = jest.fn();
|
||||
const onTransportEnabled = jest.fn();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
|
||||
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
|
||||
|
||||
transport.setEnabled({ toDevice: false, room: false });
|
||||
const dateNow = Date.now();
|
||||
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
|
||||
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
|
||||
// for room key transport we will never get a disabled message because its will always just turn on
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
|
||||
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
|
||||
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
Copyright 2025 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 { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
|
||||
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
|
||||
import { EventType, MatrixClient, RoomEvent } from "../../../src";
|
||||
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
|
||||
import type { Mocked } from "jest-mock";
|
||||
import type { Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomKeyTransport", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room & {
|
||||
emitTimelineEvent: (event: MatrixEvent) => void;
|
||||
};
|
||||
let transport: RoomKeyTransport;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
beforeEach(() => {
|
||||
onCallEncryptionMock.mockReset();
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
|
||||
const statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
room = makeMockRoom([membershipTemplate]);
|
||||
client = new MatrixClient({ baseUrl: "base_url" });
|
||||
client.matrixRTC.start();
|
||||
transport = new RoomKeyTransport(room, client, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
|
||||
onCallEncryptionMock(...p);
|
||||
});
|
||||
transport.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
client.matrixRTC.stop();
|
||||
transport.stop();
|
||||
});
|
||||
|
||||
it("Calls onCallEncryption on encryption keys event", async () => {
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
});
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
await new Promise(process.nextTick);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("event decryption", () => {
|
||||
it("Retries decryption and processes success", async () => {
|
||||
jest.useFakeTimers();
|
||||
let isDecryptionFailure = true;
|
||||
client.decryptEventIfNeeded = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(Promise.resolve())
|
||||
.mockImplementation(() => {
|
||||
isDecryptionFailure = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) },
|
||||
);
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("Retries decryption and processes failure", async () => {
|
||||
try {
|
||||
jest.useFakeTimers();
|
||||
const onCallEncryptionMock = jest.fn();
|
||||
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
|
||||
|
||||
const timelineEvent = Object.assign(
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
}),
|
||||
{ isDecryptionFailure: jest.fn().mockReturnValue(true) },
|
||||
);
|
||||
|
||||
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// should retry after one second:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
// doesn't retry again:
|
||||
await jest.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
// empty content
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {},
|
||||
}),
|
||||
// no sender
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Call_id not empty string
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "FOO",
|
||||
keys: [makeKey(0, "testKey")],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
// Various Malformed keys
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: "FOO",
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [{ index: 0 }],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
new MatrixEvent({
|
||||
type: EventType.CallEncryptionKeysPrefix,
|
||||
sender: "@alice:example.com",
|
||||
content: {
|
||||
call_id: "",
|
||||
keys: [
|
||||
{
|
||||
key: "BASE64KEY",
|
||||
index: "mcall",
|
||||
},
|
||||
],
|
||||
sent_ts: Date.now(),
|
||||
device_id: "AAAAAAA",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.onEncryptionEvent(event);
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Mocked } from "jest-mock";
|
||||
|
||||
import { makeMockEvent } from "./mocks";
|
||||
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
|
||||
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
|
||||
import { type Statistics } from "../../../src/matrixrtc";
|
||||
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
|
||||
import { type Logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("ToDeviceKeyTransport", () => {
|
||||
const roomId = "!room:id";
|
||||
|
||||
let mockClient: Mocked<MatrixClient>;
|
||||
let statistics: Statistics;
|
||||
let mockLogger: Mocked<Logger>;
|
||||
let transport: ToDeviceKeyTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
encryptAndSendToDevice: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
});
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as unknown as Mocked<Logger>;
|
||||
statistics = {
|
||||
counters: {
|
||||
roomEventEncryptionKeysSent: 0,
|
||||
roomEventEncryptionKeysReceived: 0,
|
||||
},
|
||||
totals: {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 0,
|
||||
},
|
||||
};
|
||||
|
||||
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
|
||||
getChild: jest.fn().mockReturnValue(mockLogger),
|
||||
} as unknown as Mocked<Logger>);
|
||||
});
|
||||
|
||||
it("should send my keys on via to device", async () => {
|
||||
transport.start();
|
||||
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", membershipTs: 1234 },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1234 },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
"io.element.call.encryption_keys",
|
||||
[
|
||||
{ userId: "@bob:example.org", deviceId: "BOBDEVICE" },
|
||||
{ userId: "@carl:example.org", deviceId: "CARLDEVICE" },
|
||||
{ userId: "@mat:example.org", deviceId: "MATDEVICE" },
|
||||
],
|
||||
{
|
||||
keys: {
|
||||
index: keyIndex,
|
||||
key: keyBase64Encoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "MYDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
sent_ts: expect.any(Number),
|
||||
},
|
||||
);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1);
|
||||
});
|
||||
|
||||
it("should emit when a key is received", async () => {
|
||||
const receivedKeyResolvers = Promise.withResolvers<{
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
keyBase64Encoded: string;
|
||||
index: number;
|
||||
}>();
|
||||
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
|
||||
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
|
||||
});
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: roomId,
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { userId, deviceId, keyBase64Encoded, index } = await receivedKeyResolvers.promise;
|
||||
expect(userId).toBe("@bob:example.org");
|
||||
expect(deviceId).toBe("BOBDEVICE");
|
||||
expect(keyBase64Encoded).toBe(testEncoded);
|
||||
expect(index).toBe(testKeyIndex);
|
||||
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1);
|
||||
});
|
||||
|
||||
it("should not sent to ourself", async () => {
|
||||
const keyBase64Encoded = "ABCDEDF";
|
||||
const keyIndex = 2;
|
||||
await transport.sendKey(keyBase64Encoded, keyIndex, [
|
||||
{ userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
|
||||
]);
|
||||
|
||||
transport.start();
|
||||
|
||||
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should warn when there is a room mismatch", () => {
|
||||
transport.start();
|
||||
|
||||
const testEncoded = "ABCDEDF";
|
||||
const testKeyIndex = 2;
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
|
||||
keys: {
|
||||
index: testKeyIndex,
|
||||
key: testEncoded,
|
||||
},
|
||||
member: {
|
||||
claimed_device_id: "BOBDEVICE",
|
||||
},
|
||||
room_id: "!anotherroom:id",
|
||||
session: {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
scope: "m.room",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId");
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
|
||||
describe("malformed events", () => {
|
||||
const MALFORMED_EVENT = [
|
||||
{
|
||||
keys: {},
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { index: 0 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF" },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: {},
|
||||
room_id: "!room:id",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
{
|
||||
keys: { key: "ABCDEF", index: 2 },
|
||||
member: { claimed_device_id: "MYDEVICE" },
|
||||
room_id: "!wrong_room",
|
||||
session: { application: "m.call", call_id: "", scope: "m.room" },
|
||||
},
|
||||
];
|
||||
|
||||
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
|
||||
transport.start();
|
||||
|
||||
mockClient.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event),
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
This file adds a custom test environment for the MembershipManager.spec.ts
|
||||
It can be used with the comment at the top of the file:
|
||||
|
||||
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||
|
||||
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
|
||||
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
|
||||
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
|
||||
in their test name.
|
||||
*/
|
||||
|
||||
import { TestEnvironment } from "jest-environment-jsdom";
|
||||
|
||||
import { logger as rootLogger } from "../../../src/logger";
|
||||
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||
|
||||
class MemberManagerTestEnvironment extends TestEnvironment {
|
||||
handleTestEvent(event: any) {
|
||||
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
|
||||
let parent = event.test.parent;
|
||||
let isLegacy = false;
|
||||
while (parent) {
|
||||
if (parent.name === "LegacyMembershipManager") {
|
||||
isLegacy = true;
|
||||
break;
|
||||
} else {
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
if (isLegacy) {
|
||||
logger.info("skip test: ", event.test.name);
|
||||
event.test.mode = "skip";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = MemberManagerTestEnvironment;
|
||||
@@ -14,15 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
|
||||
import { EventEmitter } from "stream";
|
||||
|
||||
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
|
||||
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||
import { secureRandomString } from "../../../src/randomstring";
|
||||
|
||||
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
|
||||
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
|
||||
|
||||
export const membershipTemplate: SessionMembershipData = {
|
||||
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
user_id: "@mock:user.example",
|
||||
device_id: "AAAAAAA",
|
||||
scope: "m.room",
|
||||
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
|
||||
@@ -65,25 +68,28 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockRoom(membershipData: MembershipData): Room {
|
||||
export function makeMockRoom(
|
||||
membershipData: MembershipData[],
|
||||
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
|
||||
const roomId = secureRandomString(8);
|
||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||
const roomState = makeMockRoomState(membershipData, roomId);
|
||||
const room = {
|
||||
const room = Object.assign(new EventEmitter(), {
|
||||
roomId: roomId,
|
||||
hasMembershipState: jest.fn().mockReturnValue(true),
|
||||
getLiveTimeline: jest.fn().mockReturnValue({
|
||||
getState: jest.fn().mockReturnValue(roomState),
|
||||
}),
|
||||
getVersion: jest.fn().mockReturnValue("default"),
|
||||
} as unknown as Room;
|
||||
return room;
|
||||
}) as unknown as Room;
|
||||
return Object.assign(room, {
|
||||
emitTimelineEvent: (event: MatrixEvent) =>
|
||||
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
|
||||
});
|
||||
}
|
||||
|
||||
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
|
||||
const events = Array.isArray(membershipData)
|
||||
? membershipData.map((m) => mockRTCEvent(m, roomId))
|
||||
: [mockRTCEvent(membershipData, roomId)];
|
||||
function makeMockRoomState(membershipData: MembershipData[], roomId: string) {
|
||||
const events = membershipData.map((m) => mockRTCEvent(m, roomId));
|
||||
const keysAndEvents = events.map((e) => {
|
||||
const data = e.getContent() as SessionMembershipData;
|
||||
return [`_${e.sender?.userId}_${data.device_id}`];
|
||||
@@ -113,17 +119,39 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||
const sender = customSender ?? "@mock:user.example";
|
||||
export function mockRoomState(room: Room, membershipData: MembershipData[]): void {
|
||||
room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId));
|
||||
}
|
||||
|
||||
export function makeMockEvent(
|
||||
type: string,
|
||||
sender: string,
|
||||
roomId: string | undefined,
|
||||
content: any,
|
||||
timestamp?: number,
|
||||
): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getContent: jest.fn().mockReturnValue(membershipData),
|
||||
getType: jest.fn().mockReturnValue(type),
|
||||
getContent: jest.fn().mockReturnValue(content),
|
||||
getSender: jest.fn().mockReturnValue(sender),
|
||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getId: jest.fn().mockReturnValue(secureRandomString(8)),
|
||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
|
||||
|
||||
export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent {
|
||||
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
|
||||
}
|
||||
|
||||
export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership {
|
||||
return new CallMembership(mockRTCEvent(membershipData, roomId), membershipData);
|
||||
}
|
||||
|
||||
export function makeKey(id: number, key: string): { key: string; index: number } {
|
||||
return {
|
||||
key: key,
|
||||
index: id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,7 +379,9 @@ describe("MatrixEvent", () => {
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.decryptionFailureReason).not.toBe(
|
||||
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
|
||||
);
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: Error: test error **",
|
||||
@@ -403,7 +405,9 @@ describe("MatrixEvent", () => {
|
||||
expect(encryptedEvent.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
);
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.decryptionFailureReason).not.toBe(
|
||||
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
|
||||
);
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: DecryptionError: uisi **",
|
||||
@@ -427,7 +431,9 @@ describe("MatrixEvent", () => {
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeTruthy();
|
||||
expect(encryptedEvent.decryptionFailureReason).toBe(
|
||||
DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
|
||||
);
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: DecryptionError: The sender has disabled encrypting to unverified devices. **",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2025 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 { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
|
||||
import type { MockedObject } from "jest-mock";
|
||||
|
||||
const CREATOR_USER_ID = "@creator:example.org";
|
||||
const MODERATOR_USER_ID = "@moderator:example.org";
|
||||
|
||||
describe("Room", () => {
|
||||
function createMockClient(): MatrixClient {
|
||||
return {
|
||||
supportsThreads: jest.fn().mockReturnValue(true),
|
||||
decryptEventIfNeeded: jest.fn().mockReturnThis(),
|
||||
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
|
||||
} as unknown as MockedObject<MatrixClient>;
|
||||
}
|
||||
|
||||
function createEvent(eventId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: eventId, // we do this for ease of use, not practicality
|
||||
},
|
||||
event_id: eventId,
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function createRedaction(redactsEventId: string): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.redaction",
|
||||
redacts: redactsEventId,
|
||||
event_id: "$redacts_" + redactsEventId.substring(1),
|
||||
sender: CREATOR_USER_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
|
||||
return room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((e) => !e.isState());
|
||||
}
|
||||
|
||||
it("should apply redactions locally", async () => {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
const messageEvent = createEvent("$message_event");
|
||||
|
||||
// Set up the room
|
||||
await room.addLiveEvents([messageEvent], { addToState: false });
|
||||
let timeline = getNonStateMainTimelineLiveEvents(room);
|
||||
expect(timeline.length).toEqual(1);
|
||||
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
|
||||
|
||||
// Now redact
|
||||
const redactionEvent = createRedaction(messageEvent.getId()!);
|
||||
await room.addLiveEvents([redactionEvent], { addToState: false });
|
||||
timeline = getNonStateMainTimelineLiveEvents(room);
|
||||
expect(timeline.length).toEqual(2);
|
||||
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||
expect(timeline[0].isRedacted()).toEqual(true); // test case
|
||||
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
|
||||
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
|
||||
});
|
||||
|
||||
describe("MSC4293: Redact on ban", () => {
|
||||
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
|
||||
const mockClient = createMockClient();
|
||||
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||
|
||||
// Pre-populate room
|
||||
const messageEvents: MatrixEvent[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
messageEvents.push(createEvent(`$message_${i}`));
|
||||
}
|
||||
await room.addLiveEvents(messageEvents, { addToState: false });
|
||||
|
||||
if (andGrantPermissions) {
|
||||
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
return { room, messageEvents };
|
||||
}
|
||||
|
||||
function createRedactOnMembershipChange(
|
||||
targetUserId: string,
|
||||
senderUserId: string,
|
||||
membership: string,
|
||||
): MatrixEvent {
|
||||
return new MatrixEvent({
|
||||
type: "m.room.member",
|
||||
state_key: targetUserId,
|
||||
content: {
|
||||
"membership": membership,
|
||||
"org.matrix.msc4293.redact_events": true,
|
||||
},
|
||||
sender: senderUserId,
|
||||
});
|
||||
}
|
||||
|
||||
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
|
||||
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
|
||||
messageEvents.find((e2) => e2.getId() === e.getId()),
|
||||
);
|
||||
expect(actualEvents.length).toEqual(messageEvents.length);
|
||||
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
|
||||
if (shouldAllBeRedacted) {
|
||||
expect(redactedEvents.length).toEqual(messageEvents.length);
|
||||
} else {
|
||||
expect(redactedEvents.length).toEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
it("should apply on ban", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||
await room.addLiveEvents([banEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, true);
|
||||
});
|
||||
|
||||
it("should apply on kick", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
|
||||
await room.addLiveEvents([kickEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, true);
|
||||
});
|
||||
|
||||
it("should not apply if the user doesn't have permission to redact", async () => {
|
||||
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
|
||||
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||
await room.addLiveEvents([banEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to self-leaves", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to invites", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to joins", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
|
||||
it("should not apply to knocks", async () => {
|
||||
const { room, messageEvents } = await setupRoom(true);
|
||||
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
|
||||
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||
|
||||
expectRedacted(messageEvents, room, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -164,6 +164,23 @@ describe("oidc authorization", () => {
|
||||
|
||||
expect(authUrl.searchParams.get("prompt")).toEqual("create");
|
||||
});
|
||||
|
||||
it("should generate url with login_hint", async () => {
|
||||
const nonce = "abc123";
|
||||
|
||||
const authUrl = new URL(
|
||||
await generateOidcAuthorizationUrl({
|
||||
metadata: delegatedAuthConfig,
|
||||
homeserverUrl: baseUrl,
|
||||
clientId,
|
||||
redirectUri: baseUrl,
|
||||
nonce,
|
||||
loginHint: "login1234",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeAuthorizationCodeGrant", () => {
|
||||
|
||||
@@ -130,10 +130,12 @@ describe("OidcTokenRefresher", () => {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should persist the new tokens", async () => {
|
||||
@@ -144,10 +146,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
await refresher.doRefreshAccessToken("refresh-token");
|
||||
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
});
|
||||
expect(refresher.persistTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have one inflight refresh request at once", async () => {
|
||||
@@ -189,10 +193,12 @@ describe("OidcTokenRefresher", () => {
|
||||
|
||||
// only one call to token endpoint
|
||||
expect(fetchMock).toHaveFetchedTimes(1, config.token_endpoint);
|
||||
expect(result1).toEqual({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
});
|
||||
expect(result1).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "first-new-access-token",
|
||||
refreshToken: "first-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
// same response
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
@@ -200,10 +206,12 @@ describe("OidcTokenRefresher", () => {
|
||||
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",
|
||||
});
|
||||
expect(third).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should log and rethrow when token refresh fails", async () => {
|
||||
@@ -261,10 +269,12 @@ describe("OidcTokenRefresher", () => {
|
||||
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",
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
accessToken: "second-new-access-token",
|
||||
refreshToken: "second-new-refresh-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TokenRefreshLogoutError when expired", async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TweakName,
|
||||
} from "../../src";
|
||||
import { mockClientMethodsUser } from "../test-utils/client";
|
||||
import { logger } from "../../src/logger.ts";
|
||||
|
||||
const msc3914RoomCallRule: IPushRule = {
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
@@ -209,7 +210,7 @@ describe("NotificationService", function () {
|
||||
msgtype: "m.text",
|
||||
},
|
||||
});
|
||||
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules!);
|
||||
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(logger, matrixClient.pushRules!);
|
||||
pushProcessor = new PushProcessor(matrixClient);
|
||||
});
|
||||
|
||||
@@ -731,7 +732,7 @@ describe("Test PushProcessor.partsForDottedKey", function () {
|
||||
|
||||
describe("rewriteDefaultRules", () => {
|
||||
it("should add default rules in the correct order", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
|
||||
device: {},
|
||||
global: {
|
||||
content: [],
|
||||
@@ -867,7 +868,7 @@ describe("rewriteDefaultRules", () => {
|
||||
});
|
||||
|
||||
it("should add missing msc3914 rule in correct place", () => {
|
||||
const pushRules = PushProcessor.rewriteDefaultRules({
|
||||
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
|
||||
device: {},
|
||||
global: {
|
||||
// Sample push rules from a Synapse user.
|
||||
|
||||
@@ -126,13 +126,15 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
// flush the 500 response
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.retryImmediately();
|
||||
|
||||
// flush the 200 response
|
||||
// longer timeout here to try & avoid flakiness
|
||||
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 3000)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries on when client is started", async function () {
|
||||
@@ -150,13 +152,15 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
|
||||
eventType: "org.example.foo",
|
||||
batch: [FAKE_MSG],
|
||||
});
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
// flush the 500 response
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
client.stopClient();
|
||||
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
// flush the 200 response
|
||||
expect(await httpBackend.flush("/sendToDevice/org.example.foo/", 1, 20)).toEqual(1);
|
||||
});
|
||||
|
||||
it("retries when a message is retried", async function () {
|
||||
|
||||
+20
-146
@@ -20,8 +20,8 @@ import * as utils from "../test-utils/test-utils";
|
||||
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
|
||||
import {
|
||||
createClient,
|
||||
EventType,
|
||||
type MatrixClient,
|
||||
MatrixEvent,
|
||||
type RoomState,
|
||||
UNSTABLE_MSC2666_MUTUAL_ROOMS,
|
||||
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
|
||||
@@ -101,158 +101,32 @@ describe("RoomMember", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPowerLevelEvent", function () {
|
||||
it("should set 'powerLevel' and 'powerLevelNorm'.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@bertha:bar": 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
member.setPowerLevelEvent(event);
|
||||
expect(member.powerLevel).toEqual(20);
|
||||
expect(member.powerLevelNorm).toEqual(10);
|
||||
|
||||
const memberB = new RoomMember(roomId, userB);
|
||||
memberB.setPowerLevelEvent(event);
|
||||
expect(memberB.powerLevel).toEqual(200);
|
||||
expect(memberB.powerLevelNorm).toEqual(100);
|
||||
});
|
||||
|
||||
it("should emit 'RoomMember.powerLevel' if the power level changes.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@bertha:bar": 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember).toEqual(member);
|
||||
expect(emitEvent).toEqual(event);
|
||||
});
|
||||
|
||||
member.setPowerLevelEvent(event);
|
||||
expect(emitCount).toEqual(1);
|
||||
member.setPowerLevelEvent(event); // no-op
|
||||
expect(emitCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should honour power levels of zero.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": 0,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
// set the power level to something other than zero or we
|
||||
// won't get an event
|
||||
member.powerLevel = 1;
|
||||
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(0);
|
||||
expect(emitEvent).toEqual(event);
|
||||
});
|
||||
|
||||
member.setPowerLevelEvent(event);
|
||||
describe("setPowerLevel", function () {
|
||||
it("should set 'powerLevel'.", function () {
|
||||
member.setPowerLevel(0, new MatrixEvent());
|
||||
expect(member.powerLevel).toEqual(0);
|
||||
expect(emitCount).toEqual(1);
|
||||
member.setPowerLevel(200, new MatrixEvent());
|
||||
expect(member.powerLevel).toEqual(200);
|
||||
});
|
||||
|
||||
it("should not honor string power levels.", function () {
|
||||
const event = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
it("should emit when power level set", function () {
|
||||
const onEmit = jest.fn();
|
||||
member.on(RoomMemberEvent.PowerLevel, onEmit);
|
||||
|
||||
member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(20);
|
||||
expect(emitEvent).toEqual(event);
|
||||
});
|
||||
const aMatrixEvent = new MatrixEvent();
|
||||
member.setPowerLevel(10, aMatrixEvent);
|
||||
|
||||
member.setPowerLevelEvent(event);
|
||||
expect(member.powerLevel).toEqual(20);
|
||||
expect(emitCount).toEqual(1);
|
||||
expect(onEmit).toHaveBeenCalledWith(aMatrixEvent, member);
|
||||
});
|
||||
|
||||
it("should no-op if given a non-state or unrelated event", () => {
|
||||
const fn = jest.spyOn(member, "emit");
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
member.setPowerLevelEvent(
|
||||
utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
skey: "invalid",
|
||||
event: true,
|
||||
}),
|
||||
);
|
||||
const nonStateEv = utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
delete nonStateEv.event.state_key;
|
||||
member.setPowerLevelEvent(nonStateEv);
|
||||
member.setPowerLevelEvent(
|
||||
utils.mkEvent({
|
||||
type: EventType.Sticker,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {},
|
||||
event: true,
|
||||
}),
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
it("should not emit if new power level is the same", function () {
|
||||
const onEmit = jest.fn();
|
||||
member.on(RoomMemberEvent.PowerLevel, onEmit);
|
||||
|
||||
const aMatrixEvent = new MatrixEvent();
|
||||
member.setPowerLevel(0, aMatrixEvent);
|
||||
|
||||
expect(onEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+197
-11
@@ -20,12 +20,12 @@ import * as utils from "../test-utils/test-utils";
|
||||
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
|
||||
import { filterEmitCallsByEventType } from "../test-utils/emitter";
|
||||
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
|
||||
import { RoomMemberEvent } from "../../src/models/room-member";
|
||||
import { type Beacon, BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
|
||||
import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { type MatrixClient } from "../../src/client";
|
||||
import { defer } from "../../src/utils";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
@@ -39,10 +39,10 @@ describe("RoomState", function () {
|
||||
const userLazy = "@lazy:bar";
|
||||
|
||||
let state = new RoomState(roomId);
|
||||
let statev12 = new RoomState(roomId);
|
||||
|
||||
beforeEach(function () {
|
||||
state = new RoomState(roomId);
|
||||
state.setStateEvents([
|
||||
const commonEvents = [
|
||||
utils.mkMembership({
|
||||
// userA joined
|
||||
event: true,
|
||||
@@ -67,6 +67,11 @@ describe("RoomState", function () {
|
||||
name: "Room name goes here",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
state = new RoomState(roomId);
|
||||
state.setStateEvents([
|
||||
...commonEvents,
|
||||
utils.mkEvent({
|
||||
// Room creation
|
||||
type: "m.room.create",
|
||||
@@ -76,6 +81,19 @@ describe("RoomState", function () {
|
||||
content: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
statev12 = new RoomState(roomId);
|
||||
statev12.setStateEvents([
|
||||
...commonEvents,
|
||||
utils.mkEvent({
|
||||
// Room creation (v12 version)
|
||||
type: "m.room.create",
|
||||
user: userA,
|
||||
room: roomId,
|
||||
event: true,
|
||||
content: { room_version: "12" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("getMembers", function () {
|
||||
@@ -260,7 +278,7 @@ describe("RoomState", function () {
|
||||
expect(emitCount).toEqual(2);
|
||||
});
|
||||
|
||||
it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function () {
|
||||
it("should call setPowerLevel on each RoomMember for m.room.power_levels", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
@@ -274,12 +292,12 @@ describe("RoomState", function () {
|
||||
});
|
||||
|
||||
// spy on the room members
|
||||
jest.spyOn(state.members[userA], "setPowerLevelEvent");
|
||||
jest.spyOn(state.members[userB], "setPowerLevelEvent");
|
||||
jest.spyOn(state.members[userA], "setPowerLevel");
|
||||
jest.spyOn(state.members[userB], "setPowerLevel");
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
|
||||
expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
|
||||
expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent);
|
||||
expect(state.members[userA].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent);
|
||||
expect(state.members[userB].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent);
|
||||
});
|
||||
|
||||
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function () {
|
||||
@@ -311,6 +329,156 @@ describe("RoomState", function () {
|
||||
expect(state.members[userC].powerLevel).toEqual(10);
|
||||
});
|
||||
|
||||
it("should calculate power level correctly", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
[userB]: 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
|
||||
expect(state.getMember(userA)?.powerLevel).toEqual(20);
|
||||
expect(state.getMember(userB)?.powerLevel).toEqual(200);
|
||||
});
|
||||
|
||||
it("should set 'powerLevel' with a v12 room.", function () {
|
||||
const createEventV12 = utils.mkEvent({
|
||||
type: "m.room.create",
|
||||
room: roomId,
|
||||
sender: userA,
|
||||
content: { room_version: "12" },
|
||||
event: true,
|
||||
});
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
[userB]: 200,
|
||||
"@invalid:user": 10, // shouldn't barf on this.
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
state.setStateEvents([createEventV12, powerLevelEvent]);
|
||||
expect(state.getMember(userA)?.powerLevel).toEqual(Infinity);
|
||||
});
|
||||
|
||||
it("should honour power levels of zero.", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": 0,
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
const memberA = state.getMember(userA)!;
|
||||
// set the power level to something other than zero or we
|
||||
// won't get an event
|
||||
memberA.powerLevel = 1;
|
||||
memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(0);
|
||||
expect(emitEvent).toEqual(powerLevelEvent);
|
||||
});
|
||||
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
expect(memberA.powerLevel).toEqual(0);
|
||||
expect(emitCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should not honor string power levels.", function () {
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: "m.room.power_levels",
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
let emitCount = 0;
|
||||
|
||||
const memberA = state.getMember(userA)!;
|
||||
memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) {
|
||||
emitCount += 1;
|
||||
expect(emitMember.userId).toEqual("@alice:bar");
|
||||
expect(emitMember.powerLevel).toEqual(20);
|
||||
expect(emitEvent).toEqual(powerLevelEvent);
|
||||
});
|
||||
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
expect(memberA.powerLevel).toEqual(20);
|
||||
expect(emitCount).toEqual(1);
|
||||
});
|
||||
|
||||
it("should no-op if given a non-state or unrelated event", () => {
|
||||
const memberA = state.getMember(userA)!;
|
||||
const fn = jest.spyOn(memberA, "emit");
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
|
||||
const powerLevelEvent = utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
skey: "invalid",
|
||||
event: true,
|
||||
});
|
||||
|
||||
state.setStateEvents([powerLevelEvent]);
|
||||
const nonStateEv = utils.mkEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {
|
||||
users_default: 20,
|
||||
users: {
|
||||
"@alice:bar": "5",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
delete nonStateEv.event.state_key;
|
||||
state.setStateEvents([nonStateEv]);
|
||||
state.setStateEvents([
|
||||
utils.mkEvent({
|
||||
type: EventType.Sticker,
|
||||
room: roomId,
|
||||
user: userA,
|
||||
content: {},
|
||||
event: true,
|
||||
}),
|
||||
]);
|
||||
expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel);
|
||||
});
|
||||
|
||||
it("should call setMembershipEvent on the right RoomMember", function () {
|
||||
const memberEvent = utils.mkMembership({
|
||||
user: userB,
|
||||
@@ -852,6 +1020,24 @@ describe("RoomState", function () {
|
||||
expect(state.maySendEvent("m.room.other_thing", userA)).toEqual(true);
|
||||
expect(state.maySendEvent("m.room.other_thing", userB)).toEqual(false);
|
||||
});
|
||||
|
||||
it("should recognise power level of room creators in v12 rooms", function () {
|
||||
const powerLevelEvent = new MatrixEvent({
|
||||
type: "m.room.power_levels",
|
||||
room_id: roomId,
|
||||
sender: userA,
|
||||
state_key: "",
|
||||
content: {
|
||||
users_default: 0,
|
||||
state_default: 100,
|
||||
events_default: 100,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
statev12.setStateEvents([powerLevelEvent]);
|
||||
|
||||
expect(statev12.maySendEvent("m.room.name", userA)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processBeaconEvents", () => {
|
||||
@@ -1084,8 +1270,8 @@ describe("RoomState", function () {
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
|
||||
const deferred = defer<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
|
||||
const decryptEventResolvers = Promise.withResolvers<void>();
|
||||
mockClient.decryptEventIfNeeded.mockReturnValue(decryptEventResolvers.promise);
|
||||
|
||||
state.setStateEvents([beacon1, beacon2]);
|
||||
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
|
||||
@@ -1095,7 +1281,7 @@ describe("RoomState", function () {
|
||||
// update type after '''decryption'''
|
||||
decryptingRelatedEvent.event.type = M_BEACON.name;
|
||||
decryptingRelatedEvent.event.content = locationEvent.event.content;
|
||||
deferred.resolve();
|
||||
decryptEventResolvers.resolve();
|
||||
await prom;
|
||||
|
||||
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { type ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("CrossSigningIdentity", () => {
|
||||
describe("bootstrapCrossSigning", () => {
|
||||
@@ -55,7 +56,7 @@ describe("CrossSigningIdentity", () => {
|
||||
store: jest.fn(),
|
||||
} as unknown as Mocked<ServerSideSecretStorage>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
crossSigning = new CrossSigningIdentity(logger, olmMachine, outgoingRequestProcessor, secretStorage);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("KeyClaimManager", () => {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
const outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
|
||||
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type UIAuthCallback,
|
||||
} from "../../../src";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("OutgoingRequestProcessor", () => {
|
||||
/** the OutgoingRequestProcessor implementation under test */
|
||||
@@ -77,7 +77,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
markRequestAsSent: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
});
|
||||
|
||||
/* simple requests that map directly to the request body */
|
||||
@@ -285,16 +285,16 @@ describe("OutgoingRequestProcessor", () => {
|
||||
new RustSdkCryptoJs.DeviceId("TEST_DEVICE"),
|
||||
);
|
||||
|
||||
const authRequestResultDefer = defer<string>();
|
||||
const authRequestResultResolvers = Promise.withResolvers<string>();
|
||||
|
||||
const authRequestCalledPromise = new Promise<void>((resolve) => {
|
||||
const mockHttpApi = {
|
||||
authedRequest: async () => {
|
||||
resolve();
|
||||
return await authRequestResultDefer.promise;
|
||||
return await authRequestResultResolvers.promise;
|
||||
},
|
||||
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
|
||||
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, mockHttpApi);
|
||||
});
|
||||
|
||||
// build a request
|
||||
@@ -308,7 +308,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
olmMachine.close();
|
||||
|
||||
// the HTTP request completes...
|
||||
authRequestResultDefer.resolve("{}");
|
||||
authRequestResultResolvers.resolve("{}");
|
||||
|
||||
// ... and `makeOutgoingRequest` resolves satisfactorily
|
||||
await result;
|
||||
@@ -326,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
|
||||
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
|
||||
describe("OutgoingRequestsManager", () => {
|
||||
@@ -70,11 +69,11 @@ describe("OutgoingRequestsManager", () => {
|
||||
const request2 = new RustSdkCryptoJs.KeysUploadRequest("foo2", "{}");
|
||||
const request3 = new RustSdkCryptoJs.KeysBackupRequest("foo3", "{}", "1");
|
||||
|
||||
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
|
||||
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
|
||||
|
||||
olmMachine.outgoingRequests
|
||||
.mockImplementationOnce(async () => {
|
||||
return firstOutgoingRequestDefer.promise;
|
||||
return firstOutgoingRequestResolvers.promise;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return [request3];
|
||||
@@ -87,7 +86,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
const thirdRequest = manager.doProcessOutgoingRequests();
|
||||
|
||||
// let the first request complete
|
||||
firstOutgoingRequestDefer.resolve([request1, request2]);
|
||||
firstOutgoingRequestResolvers.resolve([request1, request2]);
|
||||
|
||||
await firstRequest;
|
||||
await secondRequest;
|
||||
@@ -112,10 +111,10 @@ describe("OutgoingRequestsManager", () => {
|
||||
const outgoingRequestCalledPromises: Promise<void>[] = [];
|
||||
|
||||
// deferreds which will provide the results of OlmMachine.outgoingRequests
|
||||
const outgoingRequestResultDeferreds: IDeferred<OutgoingRequest[]>[] = [];
|
||||
const outgoingRequestResultDeferreds: PromiseWithResolvers<OutgoingRequest[]>[] = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const resultDeferred = defer<OutgoingRequest[]>();
|
||||
const resultDeferred = Promise.withResolvers<OutgoingRequest[]>();
|
||||
const calledPromise = new Promise<void>((resolve) => {
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(() => {
|
||||
resolve();
|
||||
@@ -187,10 +186,10 @@ describe("OutgoingRequestsManager", () => {
|
||||
it("When the manager is stopped after outgoingRequests() call, do not make sever requests", async () => {
|
||||
const request1 = new RustSdkCryptoJs.KeysQueryRequest("foo", "{}");
|
||||
|
||||
const firstOutgoingRequestDefer = defer<OutgoingRequest[]>();
|
||||
const firstOutgoingRequestResolvers = Promise.withResolvers<OutgoingRequest[]>();
|
||||
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
|
||||
return firstOutgoingRequestDefer.promise;
|
||||
return firstOutgoingRequestResolvers.promise;
|
||||
});
|
||||
|
||||
const firstRequest = manager.doProcessOutgoingRequests();
|
||||
@@ -199,7 +198,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
manager.stop();
|
||||
|
||||
// let the first request complete
|
||||
firstOutgoingRequestDefer.resolve([request1]);
|
||||
firstOutgoingRequestResolvers.resolve([request1]);
|
||||
|
||||
await firstRequest;
|
||||
|
||||
@@ -210,7 +209,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
const request1 = new RustSdkCryptoJs.KeysQueryRequest("11", "{}");
|
||||
const request2 = new RustSdkCryptoJs.KeysUploadRequest("12", "{}");
|
||||
|
||||
const firstRequestDefer = defer<void>();
|
||||
const firstRequestResolvers = Promise.withResolvers<void>();
|
||||
|
||||
olmMachine.outgoingRequests.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
|
||||
return [request1, request2];
|
||||
@@ -219,7 +218,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
processor.makeOutgoingRequest
|
||||
.mockImplementationOnce(async () => {
|
||||
manager.stop();
|
||||
return firstRequestDefer.promise;
|
||||
return firstRequestResolvers.promise;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
return;
|
||||
@@ -227,7 +226,7 @@ describe("OutgoingRequestsManager", () => {
|
||||
|
||||
const firstRequest = manager.doProcessOutgoingRequests();
|
||||
|
||||
firstRequestDefer.resolve();
|
||||
firstRequestResolvers.resolve();
|
||||
|
||||
await firstRequest;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { PerSessionKeyBackupDownloader } from "../../../src/rust-crypto/PerSessionKeyBackupDownloader";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { defer, type IDeferred } from "../../../src/utils";
|
||||
import {
|
||||
type RustBackupCryptoEventMap,
|
||||
type RustBackupCryptoEvents,
|
||||
@@ -57,15 +56,15 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let mockBackupDecryptor: Mocked<BackupDecryptor>;
|
||||
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: IDeferred<void> } };
|
||||
let expectedSession: { [roomId: string]: { [sessionId: string]: PromiseWithResolvers<void> } };
|
||||
|
||||
function expectSessionImported(roomId: string, sessionId: string) {
|
||||
const deferred = defer<void>();
|
||||
const sessionImportedResolvers = Promise.withResolvers<void>();
|
||||
if (!expectedSession[roomId]) {
|
||||
expectedSession[roomId] = {};
|
||||
}
|
||||
expectedSession[roomId][sessionId] = deferred;
|
||||
return deferred.promise;
|
||||
expectedSession[roomId][sessionId] = sessionImportedResolvers;
|
||||
return sessionImportedResolvers.promise;
|
||||
}
|
||||
|
||||
function mockClearSession(sessionId: string): Mocked<IMegolmSessionData> {
|
||||
@@ -115,9 +114,9 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
const roomId = keys[0].room_id;
|
||||
const sessionId = keys[0].session_id;
|
||||
const deferred = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (deferred) {
|
||||
deferred.resolve();
|
||||
const sessionImportedResolvers = expectedSession[roomId] && expectedSession[roomId][sessionId];
|
||||
if (sessionImportedResolvers) {
|
||||
sessionImportedResolvers.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,7 +142,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("Should download and import a missing key from backup", async () => {
|
||||
const awaitKeyImported = defer<void>();
|
||||
const awaitKeyImported = Promise.withResolvers<void>();
|
||||
const roomId = "!roomId";
|
||||
const sessionId = "sessionId";
|
||||
const expectAPICall = new Promise<void>((resolve) => {
|
||||
@@ -168,14 +167,14 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("Should not hammer the backup if the key is requested repeatedly", async () => {
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const blockOnServerRequest = Promise.withResolvers<void>();
|
||||
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/!roomId/:session_id`, async (url, request) => {
|
||||
await blockOnServerRequest.promise;
|
||||
return [mockCipherKey];
|
||||
});
|
||||
|
||||
const awaitKey2Imported = defer<void>();
|
||||
const awaitKey2Imported = Promise.withResolvers<void>();
|
||||
|
||||
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
|
||||
if (keys[0].session_id === "sessionId2") {
|
||||
@@ -267,8 +266,8 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
it("Should stop properly", async () => {
|
||||
// Simulate a call to stop while request is in flight
|
||||
const blockOnServerRequest = defer<void>();
|
||||
const requestRoomKeyCalled = defer<void>();
|
||||
const blockOnServerRequest = Promise.withResolvers<void>();
|
||||
const requestRoomKeyCalled = Promise.withResolvers<void>();
|
||||
|
||||
// Mock the request to block
|
||||
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, async (url, request) => {
|
||||
@@ -490,7 +489,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore access to private function
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const rateDeferred = defer<void>();
|
||||
const rateDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
@@ -544,7 +543,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
|
||||
// @ts-ignore
|
||||
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
|
||||
const errorDeferred = defer<void>();
|
||||
const errorDeferred = Promise.withResolvers<void>();
|
||||
|
||||
keyQuerySpy.mockImplementation(
|
||||
// @ts-ignore
|
||||
@@ -588,7 +587,7 @@ describe("PerSessionKeyBackupDownloader", () => {
|
||||
});
|
||||
|
||||
it("On Unknown error on import skip the key and continue", async () => {
|
||||
const keyImported = defer<void>();
|
||||
const keyImported = Promise.withResolvers<void>();
|
||||
mockRustBackupManager.importBackedUpRoomKeys
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error("Didn't work");
|
||||
|
||||
@@ -29,7 +29,6 @@ import { type Mocked } from "jest-mock";
|
||||
import { HistoryVisibility, type MatrixEvent, type Room, type RoomMember } from "../../../src";
|
||||
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
|
||||
import { type KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { type OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { KnownMembership } from "../../../src/@types/membership";
|
||||
import {
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
AllDevicesIsolationMode,
|
||||
OnlySignedDevicesIsolationMode,
|
||||
} from "../../../src/crypto-api";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("RoomEncryptor", () => {
|
||||
describe("History Visibility", () => {
|
||||
@@ -109,6 +109,7 @@ describe("RoomEncryptor", () => {
|
||||
} as unknown as Mocked<Room>;
|
||||
|
||||
roomEncryptor = new RoomEncryptor(
|
||||
logger,
|
||||
mockOlmMachine,
|
||||
mockKeyClaimManager,
|
||||
mockOutgoingRequestManager,
|
||||
@@ -120,8 +121,8 @@ describe("RoomEncryptor", () => {
|
||||
const defaultDevicesIsolationMode = new AllDevicesIsolationMode(false);
|
||||
|
||||
it("should ensure that there is only one shareRoomKey at a time", async () => {
|
||||
const deferredShare = defer<void>();
|
||||
const insideOlmShareRoom = defer<void>();
|
||||
const deferredShare = Promise.withResolvers<void>();
|
||||
const insideOlmShareRoom = Promise.withResolvers<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
|
||||
insideOlmShareRoom.resolve();
|
||||
@@ -149,8 +150,8 @@ describe("RoomEncryptor", () => {
|
||||
|
||||
// Regression test for https://github.com/element-hq/element-web/issues/26684
|
||||
it("Should maintain order of encryption requests", async () => {
|
||||
const firstTargetMembers = defer<void>();
|
||||
const secondTargetMembers = defer<void>();
|
||||
const firstTargetMembers = Promise.withResolvers<void>();
|
||||
const secondTargetMembers = Promise.withResolvers<void>();
|
||||
|
||||
mockOlmMachine.shareRoomKey.mockResolvedValue([]);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`RustCrypto importing and exporting room keys should import and export keys as JSON 1`] = `
|
||||
{
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
"forwarding_curve25519_key_chain": [],
|
||||
"org.matrix.msc3061.shared_history": false,
|
||||
"room_id": "!room:id",
|
||||
"sender_claimed_keys": {
|
||||
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
|
||||
},
|
||||
"sender_key": "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
|
||||
"session_id": "FYOoKQSwe4d9jhTZ/LQCZFJINjPEqZ7Or4Z08reP92M",
|
||||
"session_key": "AQAAAABZ0jXQOprFfXe41tIFmAtHxflJp4O2hM/vzQQpOazOCFeWSoW5P3Z9Q+voU3eXehMwyP8/hm/Q8xLP6/PmJdy+71se/17kdFwcDGgLxBWfa4ODM9zlI4EjKbNqmiii5loJ7rBhA/XXaw80m0hfU6zTDX/KrO55J0Pt4vJ0LDa3LBWDqCkEsHuHfY4U2fy0AmRSSDYzxKmezq+GdPK3j/dj",
|
||||
}
|
||||
`;
|
||||
@@ -8,6 +8,7 @@ import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/Outgoing
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import * as TestData from "../../test-utils/test-data";
|
||||
import { RustBackupManager, type KeyBackup } from "../../../src/rust-crypto/backup";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("Upload keys to backup", () => {
|
||||
/** The backup manager under test */
|
||||
@@ -63,7 +64,7 @@ describe("Upload keys to backup", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
rustBackupManager = new RustBackupManager(logger, mockOlmMachine, httpAPi, outgoingRequestProcessor);
|
||||
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from "debug";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
||||
import {
|
||||
BaseMigrationData,
|
||||
@@ -31,6 +32,7 @@ import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
|
||||
import { initRustCrypto } from "../../../src/rust-crypto";
|
||||
import {
|
||||
type AccountDataEvents,
|
||||
DebugLogger,
|
||||
type Device,
|
||||
DeviceVerification,
|
||||
type EmptyObject,
|
||||
@@ -45,7 +47,7 @@ import {
|
||||
MemoryCryptoStore,
|
||||
TypedEventEmitter,
|
||||
} from "../../../src";
|
||||
import { emitPromise, mkEvent } from "../../test-utils/test-utils";
|
||||
import { emitPromise, mkEvent, waitFor } from "../../test-utils/test-utils";
|
||||
import { type CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { type IEventDecryptionResult, type IMegolmSessionData } from "../../../src/@types/crypto";
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
@@ -68,8 +70,6 @@ import {
|
||||
import * as testData from "../../test-utils/test-data";
|
||||
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { defer } from "../../../src/utils";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
|
||||
import { ClientEvent, type ClientEventHandlerMap } from "../../../src/client";
|
||||
import { type Curve25519AuthData } from "../../../src/crypto-api/keybackup";
|
||||
@@ -118,6 +118,7 @@ describe("initRustCrypto", () => {
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
@@ -129,8 +130,8 @@ describe("initRustCrypto", () => {
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase");
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase", logger);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
|
||||
});
|
||||
|
||||
it("passes through the store params (key)", async () => {
|
||||
@@ -141,6 +142,7 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const storeKey = new Uint8Array(32);
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
@@ -152,8 +154,8 @@ describe("initRustCrypto", () => {
|
||||
storeKey: storeKey,
|
||||
});
|
||||
|
||||
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey, logger);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
|
||||
});
|
||||
|
||||
it("suppresses the storePassphrase and storeKey if storePrefix is unset", async () => {
|
||||
@@ -163,6 +165,7 @@ describe("initRustCrypto", () => {
|
||||
const testOlmMachine = makeTestOlmMachine();
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: {} as MatrixClient["http"],
|
||||
@@ -175,8 +178,8 @@ describe("initRustCrypto", () => {
|
||||
storePassphrase: "storePassphrase",
|
||||
});
|
||||
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith();
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
|
||||
expect(StoreHandle.open).toHaveBeenCalledWith(null, null, logger);
|
||||
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore, logger);
|
||||
});
|
||||
|
||||
it("Should get secrets from inbox on start", async () => {
|
||||
@@ -187,7 +190,7 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
logger: new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto")),
|
||||
http: {} as MatrixClient["http"],
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
@@ -252,9 +255,10 @@ describe("initRustCrypto", () => {
|
||||
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
// console.log(`migrated ${progress} of ${total}`);
|
||||
}
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: makeMatrixHttpApi(),
|
||||
@@ -274,6 +278,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(BaseMigrationData),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
|
||||
expect(data.pickledAccount).toEqual("not a real account");
|
||||
@@ -290,6 +295,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
// First call should have 50 entries; second should have 10
|
||||
const sessions1: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[0][0];
|
||||
@@ -312,6 +318,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
// First call should have 50 entries; second should have 10
|
||||
const megolmSessions1: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
|
||||
@@ -359,11 +366,11 @@ describe("initRustCrypto", () => {
|
||||
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
|
||||
|
||||
function legacyMigrationProgressListener(progress: number, total: number): void {
|
||||
logger.log(`migrated ${progress} of ${total}`);
|
||||
// console.log(`migrated ${progress} of ${total}`);
|
||||
}
|
||||
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
logger: new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto")),
|
||||
http: makeMatrixHttpApi(),
|
||||
userId: TEST_USER,
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
@@ -401,6 +408,7 @@ describe("initRustCrypto", () => {
|
||||
);
|
||||
|
||||
const PICKLE_KEY = "pickle1234";
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
|
||||
await initRustCrypto({
|
||||
logger,
|
||||
http: makeMatrixHttpApi(),
|
||||
@@ -419,6 +427,7 @@ describe("initRustCrypto", () => {
|
||||
expect.any(Array),
|
||||
new Uint8Array(Buffer.from(PICKLE_KEY)),
|
||||
mockStore,
|
||||
logger,
|
||||
);
|
||||
const megolmSessions: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
|
||||
.calls[0][0];
|
||||
@@ -514,11 +523,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should import and export keys as JSON", async () => {
|
||||
@@ -539,11 +545,8 @@ describe("RustCrypto", () => {
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(someRoomKeys.length);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
const exportedKey = keys.find((k) => k.session_id == someRoomKeys[0].session_id);
|
||||
expect(exportedKey).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -558,11 +561,11 @@ describe("RustCrypto", () => {
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
|
||||
];
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
const res = (await rustCrypto.preprocessToDeviceMessages(inputs)).map((p) => p.message);
|
||||
expect(res).toEqual(inputs);
|
||||
});
|
||||
|
||||
it("should pass through bad encrypted messages", async () => {
|
||||
it("should fail to process bad encrypted messages", async () => {
|
||||
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
|
||||
const keys = olmMachine.identityKeys;
|
||||
const inputs: IToDeviceEvent[] = [
|
||||
@@ -583,7 +586,7 @@ describe("RustCrypto", () => {
|
||||
];
|
||||
|
||||
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
|
||||
expect(res).toEqual(inputs);
|
||||
expect(res.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
|
||||
@@ -820,7 +823,7 @@ describe("RustCrypto", () => {
|
||||
} as unknown as OlmMachine;
|
||||
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
mockOlmMachine,
|
||||
fetchMock as unknown as MatrixHttpApi<any>,
|
||||
TEST_USER,
|
||||
@@ -905,6 +908,7 @@ describe("RustCrypto", () => {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
const logger = new DebugLogger(debug("matrix-js-sdk:test:RustCrypto"));
|
||||
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
|
||||
|
||||
rustCrypto = new RustCrypto(
|
||||
@@ -934,8 +938,8 @@ describe("RustCrypto", () => {
|
||||
|
||||
it("should go round the loop again if another sync completes while the first `outgoingRequests` is running", async () => {
|
||||
// the first call to `outgoingMessages` will return a promise which blocks for a while
|
||||
const firstOutgoingRequestsDefer = defer<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsDefer.promise);
|
||||
const firstOutgoingRequestsResolvers = Promise.withResolvers<Array<any>>();
|
||||
mocked(olmMachine.outgoingRequests).mockReturnValueOnce(firstOutgoingRequestsResolvers.promise);
|
||||
|
||||
// the second will return a KeysQueryRequest.
|
||||
const testReq = new KeysQueryRequest("1234", "{}");
|
||||
@@ -952,7 +956,7 @@ describe("RustCrypto", () => {
|
||||
|
||||
// the first call now completes, *with an empty result*, which would normally cause us to exit the loop, but
|
||||
// we should have a second call queued. It should trigger a call to `makeOutgoingRequest`.
|
||||
firstOutgoingRequestsDefer.resolve([]);
|
||||
firstOutgoingRequestsResolvers.resolve([]);
|
||||
await awaitCallToMakeOutgoingRequest();
|
||||
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -964,7 +968,11 @@ describe("RustCrypto", () => {
|
||||
|
||||
const outgoingRequestProcessor = {} as unknown as OutgoingRequestProcessor;
|
||||
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
|
||||
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
|
||||
const outgoingRequestsManager = new OutgoingRequestsManager(
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
outgoingRequestProcessor,
|
||||
);
|
||||
rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager;
|
||||
|
||||
// The second time we do a /keys/upload, the `device_keys` property
|
||||
@@ -1022,7 +1030,7 @@ describe("RustCrypto", () => {
|
||||
getRoomEventEncryptionInfo: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1109,6 +1117,7 @@ describe("RustCrypto", () => {
|
||||
|
||||
it.each([
|
||||
[undefined, undefined, null],
|
||||
["Other", -1, EventShieldReason.UNKNOWN],
|
||||
[
|
||||
"Encrypted by an unverified user.",
|
||||
RustSdkCryptoJs.ShieldStateCode.UnverifiedIdentity,
|
||||
@@ -1135,6 +1144,11 @@ describe("RustCrypto", () => {
|
||||
RustSdkCryptoJs.ShieldStateCode.VerificationViolation,
|
||||
EventShieldReason.VERIFICATION_VIOLATION,
|
||||
],
|
||||
[
|
||||
"Mismatched sender",
|
||||
RustSdkCryptoJs.ShieldStateCode.MismatchedSender,
|
||||
EventShieldReason.MISMATCHED_SENDER,
|
||||
],
|
||||
])("gets the right shield reason (%s)", async (rustReason, rustCode, expectedReason) => {
|
||||
// suppress the warning from the unknown shield reason
|
||||
jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
@@ -1235,7 +1249,7 @@ describe("RustCrypto", () => {
|
||||
getDevice: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1478,7 +1492,7 @@ describe("RustCrypto", () => {
|
||||
getIdentity: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1544,14 +1558,6 @@ describe("RustCrypto", () => {
|
||||
const e2eKeyReceiver = new E2EKeyReceiver("http://server");
|
||||
const e2eKeyResponder = new E2EKeyResponder("http://server");
|
||||
e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver);
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
await expect(rustCrypto.pinCurrentUserIdentity(TEST_USER)).rejects.toThrow(
|
||||
"Cannot pin identity of own user",
|
||||
@@ -1565,7 +1571,7 @@ describe("RustCrypto", () => {
|
||||
getIdentity: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
{} as MatrixClient["http"],
|
||||
TEST_USER,
|
||||
@@ -1637,7 +1643,7 @@ describe("RustCrypto", () => {
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
const rustCrypto = new RustCrypto(
|
||||
logger,
|
||||
new DebugLogger(debug("matrix-js-sdk:test:RustCrypto")),
|
||||
olmMachine,
|
||||
makeMatrixHttpApi(),
|
||||
testData.TEST_USER_ID,
|
||||
@@ -1789,14 +1795,6 @@ describe("RustCrypto", () => {
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
||||
|
||||
// dehydration requires secret storage and cross signing
|
||||
@@ -1930,14 +1928,6 @@ describe("RustCrypto", () => {
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {
|
||||
status: 200,
|
||||
body: {},
|
||||
});
|
||||
rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
||||
|
||||
// dehydration requires secret storage and cross signing
|
||||
@@ -2255,10 +2245,10 @@ describe("RustCrypto", () => {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
beforeEach(() => {
|
||||
secretStorage = {
|
||||
setDefaultKeyId: jest.fn(),
|
||||
setDefaultKeyId: jest.fn().mockResolvedValue(undefined),
|
||||
hasKey: jest.fn().mockResolvedValue(false),
|
||||
getKey: jest.fn().mockResolvedValue(null),
|
||||
store: jest.fn(),
|
||||
store: jest.fn().mockResolvedValue(undefined),
|
||||
getDefaultKeyId: jest.fn().mockResolvedValue("defaultKeyId"),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
@@ -2298,8 +2288,9 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
|
||||
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
|
||||
|
||||
// We have a key backup
|
||||
expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull();
|
||||
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
|
||||
|
||||
const authUploadDeviceSigningKeys = jest.fn();
|
||||
await rustCrypto.resetEncryption(authUploadDeviceSigningKeys);
|
||||
@@ -2355,6 +2346,76 @@ describe("RustCrypto", () => {
|
||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeAcceptKeyBundle", () => {
|
||||
let mockOlmMachine: Mocked<OlmMachine>;
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockOlmMachine = {
|
||||
getReceivedRoomKeyBundleData: jest.fn(),
|
||||
receiveRoomKeyBundle: jest.fn(),
|
||||
} as unknown as Mocked<OlmMachine>;
|
||||
|
||||
const http = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||
baseUrl: "http://server/",
|
||||
prefix: "",
|
||||
onlyData: true,
|
||||
});
|
||||
|
||||
rustCrypto = new RustCrypto(
|
||||
new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec:maybeAcceptKeyBundle")),
|
||||
mockOlmMachine,
|
||||
http,
|
||||
TEST_USER,
|
||||
TEST_DEVICE_ID,
|
||||
{} as ServerSideSecretStorage,
|
||||
{} as CryptoCallbacks,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing if there is no key bundle", async () => {
|
||||
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(undefined);
|
||||
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
|
||||
expect(mockOlmMachine.getReceivedRoomKeyBundleData).toHaveBeenCalledTimes(1);
|
||||
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][0].toString()).toEqual("!room_id");
|
||||
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][1].toString()).toEqual("@bob:example.org");
|
||||
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the bundle via http and throws an error on failure", async () => {
|
||||
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
|
||||
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
|
||||
|
||||
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
|
||||
status: 404,
|
||||
body: {
|
||||
errcode: "M_NOT_FOUND",
|
||||
error: "Not found",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(() => rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org")).rejects.toMatchObject({
|
||||
errcode: "M_NOT_FOUND",
|
||||
httpStatus: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the bundle via http and passes it back into the OlmMachine", async () => {
|
||||
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
|
||||
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
|
||||
|
||||
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
|
||||
body: "asdfghjkl",
|
||||
});
|
||||
|
||||
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
|
||||
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Build a MatrixHttpApi instance */
|
||||
@@ -2378,7 +2439,7 @@ async function makeTestRustCrypto(
|
||||
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
|
||||
): Promise<RustCrypto> {
|
||||
return await initRustCrypto({
|
||||
logger,
|
||||
logger: new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec")),
|
||||
http,
|
||||
userId,
|
||||
deviceId,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { type IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import { EventType, MatrixEvent, MsgType } from "../../../src";
|
||||
import { logger } from "../../../src/logger.ts";
|
||||
|
||||
describe("VerificationRequest", () => {
|
||||
describe("pending", () => {
|
||||
@@ -147,6 +148,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -174,6 +176,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -278,6 +281,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -305,6 +309,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -392,6 +397,7 @@ describe("VerificationRequest", () => {
|
||||
methods,
|
||||
);
|
||||
const aliceVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
aliceOlmMachine,
|
||||
innerVerificationRequest,
|
||||
aliceRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -419,6 +425,7 @@ describe("VerificationRequest", () => {
|
||||
"$m.key.verification.request",
|
||||
)!;
|
||||
const bobVerificationRequest = new RustVerificationRequest(
|
||||
logger,
|
||||
bobOlmMachine,
|
||||
bobInnerVerificationRequest,
|
||||
bobRequestLoop as unknown as OutgoingRequestProcessor,
|
||||
@@ -496,7 +503,7 @@ function makeTestRequest(
|
||||
inner ??= makeMockedInner();
|
||||
olmMachine ??= {} as RustSdkCryptoJs.OlmMachine;
|
||||
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
|
||||
return new RustVerificationRequest(olmMachine, inner, outgoingRequestProcessor, []);
|
||||
return new RustVerificationRequest(logger, olmMachine, inner, outgoingRequestProcessor, []);
|
||||
}
|
||||
|
||||
/** Mock up a rust-side VerificationRequest */
|
||||
|
||||
+10
-11
@@ -1,7 +1,6 @@
|
||||
// This file had a function whose name is all caps, which displeases eslint
|
||||
/* eslint new-cap: "off" */
|
||||
|
||||
import { defer, type IDeferred } from "../../src/utils";
|
||||
import { MatrixError } from "../../src/http-api";
|
||||
import { MatrixScheduler } from "../../src/scheduler";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
@@ -14,7 +13,7 @@ describe("MatrixScheduler", function () {
|
||||
let scheduler: MatrixScheduler<Record<string, boolean>>;
|
||||
let retryFn: ((event: MatrixEvent | null, attempt: number, err: MatrixError) => number) | null;
|
||||
let queueFn: ((event: MatrixEvent) => string | null) | null;
|
||||
let deferred: IDeferred<Record<string, boolean>>;
|
||||
let deferred: PromiseWithResolvers<Record<string, boolean>>;
|
||||
const roomId = "!foo:bar";
|
||||
const eventA = utils.mkMessage({
|
||||
user: "@alice:bar",
|
||||
@@ -44,7 +43,7 @@ describe("MatrixScheduler", function () {
|
||||
);
|
||||
retryFn = null;
|
||||
queueFn = null;
|
||||
deferred = defer();
|
||||
deferred = Promise.withResolvers();
|
||||
});
|
||||
|
||||
it("should process events in a queue in a FIFO manner", async function () {
|
||||
@@ -54,8 +53,8 @@ describe("MatrixScheduler", function () {
|
||||
queueFn = function () {
|
||||
return "one_big_queue";
|
||||
};
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferB = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const deferB = Promise.withResolvers<Record<string, boolean>>();
|
||||
let yieldedA = false;
|
||||
scheduler.setProcessFunction(function (event) {
|
||||
if (yieldedA) {
|
||||
@@ -79,9 +78,9 @@ describe("MatrixScheduler", function () {
|
||||
|
||||
it("should invoke the retryFn on failure and wait the amount of time specified", async function () {
|
||||
const waitTimeMs = 1500;
|
||||
const retryDefer = defer();
|
||||
const retryResolvers = Promise.withResolvers<void>();
|
||||
retryFn = function () {
|
||||
retryDefer.resolve();
|
||||
retryResolvers.resolve();
|
||||
return waitTimeMs;
|
||||
};
|
||||
queueFn = function () {
|
||||
@@ -109,7 +108,7 @@ describe("MatrixScheduler", function () {
|
||||
await Promise.resolve();
|
||||
expect(procCount).toEqual(1);
|
||||
deferred.reject({});
|
||||
await retryDefer.promise;
|
||||
await retryResolvers.promise;
|
||||
expect(procCount).toEqual(1);
|
||||
jest.advanceTimersByTime(waitTimeMs);
|
||||
await Promise.resolve();
|
||||
@@ -127,8 +126,8 @@ describe("MatrixScheduler", function () {
|
||||
return "yep";
|
||||
};
|
||||
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferB = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const deferB = Promise.withResolvers<Record<string, boolean>>();
|
||||
let procCount = 0;
|
||||
scheduler.setProcessFunction(function (ev) {
|
||||
procCount += 1;
|
||||
@@ -180,7 +179,7 @@ describe("MatrixScheduler", function () {
|
||||
};
|
||||
|
||||
const expectOrder = [eventA.getId(), eventB.getId(), eventD.getId()];
|
||||
const deferA = defer<Record<string, boolean>>();
|
||||
const deferA = Promise.withResolvers<Record<string, boolean>>();
|
||||
const allExpectedEventsSeenInOrderPromise = new Promise((resolve) => {
|
||||
scheduler.setProcessFunction(function (event) {
|
||||
const id = expectOrder.shift();
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { secureRandomString } from "../../src/randomstring";
|
||||
import { type SecretInfo } from "../../src/secret-storage.ts";
|
||||
import { type AccountDataEvents, ClientEvent, MatrixEvent, TypedEventEmitter } from "../../src";
|
||||
import { defer, type IDeferred } from "../../src/utils";
|
||||
|
||||
declare module "../../src/@types/event" {
|
||||
interface SecretStorageAccountDataEvents {
|
||||
@@ -289,10 +288,10 @@ describe("ServerSideSecretStorageImpl", function () {
|
||||
describe("setDefaultKeyId", function () {
|
||||
let secretStorage: ServerSideSecretStorage;
|
||||
let accountDataAdapter: Mocked<AccountDataClient>;
|
||||
let accountDataPromise: IDeferred<void>;
|
||||
let accountDataPromise: PromiseWithResolvers<void>;
|
||||
beforeEach(() => {
|
||||
accountDataAdapter = mockAccountDataClient();
|
||||
accountDataPromise = defer();
|
||||
accountDataPromise = Promise.withResolvers();
|
||||
accountDataAdapter.setAccountData.mockImplementation(() => {
|
||||
accountDataPromise.resolve();
|
||||
return Promise.resolve({});
|
||||
|
||||
@@ -18,7 +18,6 @@ import "fake-indexeddb/auto";
|
||||
|
||||
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
function setupWorker(worker: IndexedDBStoreWorker): void {
|
||||
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
|
||||
@@ -27,16 +26,16 @@ function setupWorker(worker: IndexedDBStoreWorker): void {
|
||||
|
||||
describe("IndexedDBStore Worker", () => {
|
||||
it("should pass 'closed' event via postMessage", async () => {
|
||||
const deferred = defer<void>();
|
||||
const postMessageSuccessResolvers = Promise.withResolvers<void>();
|
||||
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
|
||||
if (seq === 1 && command === "cmd_success") {
|
||||
deferred.resolve();
|
||||
postMessageSuccessResolvers.resolve();
|
||||
}
|
||||
});
|
||||
const worker = new IndexedDBStoreWorker(postMessage);
|
||||
setupWorker(worker);
|
||||
|
||||
await deferred.promise;
|
||||
await postMessageSuccessResolvers.promise;
|
||||
|
||||
// @ts-ignore - private field access
|
||||
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { IDBFactory } from "fake-indexeddb";
|
||||
import { IndexedDBStore, type IStateEventWithRoomId, MemoryStore, User, UserEvent } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { type LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
|
||||
import { defer } from "../../../src/utils";
|
||||
|
||||
describe("IndexedDBStore", () => {
|
||||
afterEach(() => {
|
||||
@@ -80,7 +79,7 @@ describe("IndexedDBStore", () => {
|
||||
it("Should load presence events on startup", async () => {
|
||||
// 1. Create idb database
|
||||
const indexedDB = new IDBFactory();
|
||||
const setupDefer = defer<Event>();
|
||||
const setupResolvers = Promise.withResolvers<Event>();
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
let db: IDBDatabase;
|
||||
req.onupgradeneeded = () => {
|
||||
@@ -89,11 +88,11 @@ describe("IndexedDBStore", () => {
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = setupDefer.resolve;
|
||||
await setupDefer.promise;
|
||||
req.onsuccess = setupResolvers.resolve;
|
||||
await setupResolvers.promise;
|
||||
|
||||
// 2. Fill in user presence data
|
||||
const writeDefer = defer<Event>();
|
||||
const writeResolvers = Promise.withResolvers<Event>();
|
||||
const transaction = db!.transaction(["users"], "readwrite");
|
||||
const objectStore = transaction.objectStore("users");
|
||||
const request = objectStore.put({
|
||||
@@ -106,8 +105,8 @@ describe("IndexedDBStore", () => {
|
||||
type: "m.presence",
|
||||
},
|
||||
});
|
||||
request.onsuccess = writeDefer.resolve;
|
||||
await writeDefer.promise;
|
||||
request.onsuccess = writeResolvers.resolve;
|
||||
await writeResolvers.promise;
|
||||
|
||||
// 3. Close database
|
||||
req.result.close();
|
||||
@@ -201,7 +200,7 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
|
||||
it("should resolve isNewlyCreated to false if database existed already but needs upgrade", async () => {
|
||||
const deferred = defer<Event>();
|
||||
const requestSuccessResolvers = Promise.withResolvers<Event>();
|
||||
// seed db3 to Version 1 so it forces a migration
|
||||
const req = indexedDB.open("matrix-js-sdk:db3", 1);
|
||||
req.onupgradeneeded = () => {
|
||||
@@ -210,8 +209,8 @@ describe("IndexedDBStore", () => {
|
||||
db.createObjectStore("accountData", { keyPath: ["type"] });
|
||||
db.createObjectStore("sync", { keyPath: ["clobber"] });
|
||||
};
|
||||
req.onsuccess = deferred.resolve;
|
||||
await deferred.promise;
|
||||
req.onsuccess = requestSuccessResolvers.resolve;
|
||||
await requestSuccessResolvers.promise;
|
||||
req.result.close();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
@@ -232,20 +231,20 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
await store.startup();
|
||||
|
||||
const deferred = defer<void>();
|
||||
store.on("closed", deferred.resolve);
|
||||
const storeClosedResolvers = Promise.withResolvers<void>();
|
||||
store.on("closed", storeClosedResolvers.resolve);
|
||||
|
||||
// @ts-ignore - private field access
|
||||
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
|
||||
await deferred.promise;
|
||||
await storeClosedResolvers.promise;
|
||||
});
|
||||
|
||||
it("should use remote backend if workerFactory passed", async () => {
|
||||
const deferred = defer<void>();
|
||||
const workerPostMessageResolvers = Promise.withResolvers<void>();
|
||||
class MockWorker {
|
||||
postMessage(data: any) {
|
||||
if (data.command === "setupWorker") {
|
||||
deferred.resolve();
|
||||
workerPostMessageResolvers.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +256,7 @@ describe("IndexedDBStore", () => {
|
||||
workerFactory: () => new MockWorker() as Worker,
|
||||
});
|
||||
store.startup();
|
||||
await deferred.promise;
|
||||
await workerPostMessageResolvers.promise;
|
||||
});
|
||||
|
||||
it("remote worker should pass closed event", async () => {
|
||||
@@ -273,10 +272,10 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
store.startup();
|
||||
|
||||
const deferred = defer<void>();
|
||||
store.on("closed", deferred.resolve);
|
||||
const storeClosedResolvers = Promise.withResolvers<void>();
|
||||
store.on("closed", storeClosedResolvers.resolve);
|
||||
(worker as any).onmessage({ data: { command: "closed" } });
|
||||
await deferred.promise;
|
||||
await storeClosedResolvers.promise;
|
||||
});
|
||||
|
||||
it("remote worker should pass command failures", async () => {
|
||||
|
||||
+8
-1
@@ -51,7 +51,11 @@ import {
|
||||
type SDPStreamMetadata,
|
||||
type SDPStreamMetadataKey,
|
||||
} from "../webrtc/callEventTypes.ts";
|
||||
import { type EncryptionKeysEventContent, type ICallNotifyContent } from "../matrixrtc/types.ts";
|
||||
import {
|
||||
type IRTCNotificationContent,
|
||||
type EncryptionKeysEventContent,
|
||||
type ICallNotifyContent,
|
||||
} from "../matrixrtc/types.ts";
|
||||
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
|
||||
import { type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
|
||||
import { type LocalNotificationSettings } from "./local_notifications.ts";
|
||||
@@ -147,6 +151,7 @@ export enum EventType {
|
||||
|
||||
// MatrixRTC events
|
||||
CallNotify = "org.matrix.msc4075.call.notify",
|
||||
RTCNotification = "org.matrix.msc4075.rtc.notification",
|
||||
}
|
||||
|
||||
export enum RelationType {
|
||||
@@ -158,6 +163,7 @@ export enum RelationType {
|
||||
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
|
||||
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
|
||||
Thread = "m.thread",
|
||||
unstable_RTCNotificationParent = "org.matrix.msc4075.rtc.notification.parent",
|
||||
}
|
||||
|
||||
export enum MsgType {
|
||||
@@ -325,6 +331,7 @@ export interface TimelineEvents {
|
||||
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
|
||||
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
|
||||
[EventType.CallNotify]: ICallNotifyContent;
|
||||
[EventType.RTCNotification]: IRTCNotificationContent;
|
||||
[M_BEACON.name]: MBeaconEventContent;
|
||||
[M_POLL_START.name]: PollStartEventContent;
|
||||
[M_POLL_END.name]: PollEndEventContent;
|
||||
|
||||
Vendored
+7
-31
@@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// this is needed to tell TS about globalThis.Olm
|
||||
import "@matrix-org/olm";
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
@@ -34,39 +31,18 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaDevices {
|
||||
// This is experimental and types don't know about it yet
|
||||
// https://github.com/microsoft/TypeScript/issues/33232
|
||||
getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
|
||||
getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
|
||||
// Chrome-specific getUserMedia constraints
|
||||
interface MediaTrackConstraints {
|
||||
mandatory?: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DesktopCapturerConstraints {
|
||||
audio:
|
||||
| boolean
|
||||
| {
|
||||
mandatory: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
video:
|
||||
| boolean
|
||||
| {
|
||||
mandatory: {
|
||||
chromeMediaSource: string;
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface DummyInterfaceWeShouldntBeUsingThis {}
|
||||
|
||||
interface Navigator {
|
||||
// We check for the webkit-prefixed getUserMedia to detect if we're
|
||||
// on webkit: we should check if we still need to do this
|
||||
webkitGetUserMedia?: DummyInterfaceWeShouldntBeUsingThis;
|
||||
webkitGetUserMedia?: unknown;
|
||||
}
|
||||
|
||||
export interface Uint8ArrayToBase64Options {
|
||||
|
||||
+25
-7
@@ -27,13 +27,6 @@ import { type EventType, type RelationType, type RoomType } from "./event.ts";
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
export interface IJoinRoomOpts {
|
||||
/**
|
||||
* True to do a room initial sync on the resulting
|
||||
* room. If false, the <strong>returned Room object will have no current state.
|
||||
* </strong> Default: true.
|
||||
*/
|
||||
syncRoom?: boolean;
|
||||
|
||||
/**
|
||||
* If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
|
||||
*/
|
||||
@@ -43,6 +36,31 @@ export interface IJoinRoomOpts {
|
||||
* The server names to try and join through in addition to those that are automatically chosen.
|
||||
*/
|
||||
viaServers?: string[];
|
||||
|
||||
/**
|
||||
* When accepting an invite, whether to accept encrypted history shared by the inviter via the experimental
|
||||
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
acceptSharedHistory?: boolean;
|
||||
}
|
||||
|
||||
/** Options object for {@link MatrixClient.invite}. */
|
||||
export interface InviteOpts {
|
||||
/**
|
||||
* The reason for the invite.
|
||||
*/
|
||||
reason?: string;
|
||||
|
||||
/**
|
||||
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
|
||||
* visibility was `shared`, via the experimental
|
||||
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
shareEncryptedHistory?: boolean;
|
||||
}
|
||||
|
||||
export interface KnockRoomOpts {
|
||||
|
||||
+3
-6
@@ -13,7 +13,7 @@ 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 { UnstableValue } from "../NamespacedValue.ts";
|
||||
import { NamespacedValue } from "../NamespacedValue.ts";
|
||||
import { type IMessageRendering } from "./extensible_events.ts";
|
||||
|
||||
/**
|
||||
@@ -42,20 +42,17 @@ import { type IMessageRendering } from "./extensible_events.ts";
|
||||
/**
|
||||
* The event type for an m.topic event (in content)
|
||||
*/
|
||||
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
|
||||
export const M_TOPIC = new NamespacedValue("m.topic");
|
||||
|
||||
/**
|
||||
* The event content for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicContent = IMessageRendering[];
|
||||
|
||||
type MTopicStable = { [M_TOPIC.altName]: MTopicContent };
|
||||
type MTopicUnstable = { [M_TOPIC.name]: MTopicContent };
|
||||
|
||||
/**
|
||||
* The event definition for an m.topic event (in content)
|
||||
*/
|
||||
export type MTopicEvent = (MTopicStable & MTopicUnstable) | MTopicStable | MTopicUnstable;
|
||||
export type MTopicEvent = { "m.topic": MTopicContent };
|
||||
|
||||
/**
|
||||
* The event content for an m.room.topic event
|
||||
|
||||
@@ -22,9 +22,3 @@ import { type AuthDict } from "../interactive-auth.ts";
|
||||
export type UIARequest<T> = T & {
|
||||
auth?: AuthDict;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper type to represent HTTP response body for a UIA enabled endpoint
|
||||
* @deprecated - a successful response for a UIA enabled endpoint is no different, UIA is signalled via an error
|
||||
*/
|
||||
export type UIAResponse<T> = T;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Optional } from "matrix-events-sdk/lib/types";
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
/**
|
||||
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
|
||||
|
||||
+13
-10
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ToDeviceMessageId } from "./@types/event.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { type Logger } from "./logger.ts";
|
||||
import { type MatrixClient, ClientEvent } from "./client.ts";
|
||||
import { type MatrixError } from "./http-api/index.ts";
|
||||
import {
|
||||
@@ -40,7 +40,10 @@ export class ToDeviceMessageQueue {
|
||||
private retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryAttempts = 0;
|
||||
|
||||
public constructor(private client: MatrixClient) {}
|
||||
public constructor(
|
||||
private client: MatrixClient,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
this.running = true;
|
||||
@@ -67,7 +70,7 @@ export class ToDeviceMessageQueue {
|
||||
const msgmap = batchWithTxnId.batch.map(
|
||||
(msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
|
||||
);
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
|
||||
msgmap,
|
||||
);
|
||||
@@ -83,7 +86,7 @@ export class ToDeviceMessageQueue {
|
||||
|
||||
if (this.sending || !this.running) return;
|
||||
|
||||
logger.debug("Attempting to send queued to-device messages");
|
||||
this.logger.debug("Attempting to send queued to-device messages");
|
||||
|
||||
this.sending = true;
|
||||
let headBatch: IndexedToDeviceBatch | null;
|
||||
@@ -99,7 +102,7 @@ export class ToDeviceMessageQueue {
|
||||
// Make sure we're still running after the async tasks: if not, stop.
|
||||
if (!this.running) return;
|
||||
|
||||
logger.debug("All queued to-device messages sent");
|
||||
this.logger.debug("All queued to-device messages sent");
|
||||
} catch (e) {
|
||||
++this.retryAttempts;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -109,15 +112,15 @@ export class ToDeviceMessageQueue {
|
||||
// the scheduler function doesn't differentiate between fatal errors and just getting
|
||||
// bored and giving up for now
|
||||
if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
|
||||
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
this.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
|
||||
await this.client.store.removeToDeviceBatch(headBatch!.id);
|
||||
} else {
|
||||
logger.info("Automatic retry limit reached for to-device messages.");
|
||||
this.logger.info("Automatic retry limit reached for to-device messages.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
|
||||
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
@@ -133,7 +136,7 @@ export class ToDeviceMessageQueue {
|
||||
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
this.logger.info(
|
||||
`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
|
||||
);
|
||||
|
||||
@@ -146,7 +149,7 @@ export class ToDeviceMessageQueue {
|
||||
*/
|
||||
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
|
||||
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
|
||||
logger.info(`Resuming queue after resumed sync`);
|
||||
this.logger.info(`Resuming queue after resumed sync`);
|
||||
this.sendQueue();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -462,7 +462,7 @@ export class AutoDiscovery {
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
raw: {},
|
||||
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||
|
||||
+205
-158
@@ -52,7 +52,7 @@ import {
|
||||
type GroupCallEventHandlerEventHandlerMap,
|
||||
} from "./webrtc/groupCallEventHandler.ts";
|
||||
import * as utils from "./utils.ts";
|
||||
import { deepCompare, defer, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
|
||||
import { deepCompare, noUnsafeEventProps, type QueryDict, replaceParam, safeSet, sleep } from "./utils.ts";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline.ts";
|
||||
import { type IActionsObject, PushProcessor } from "./pushprocessor.ts";
|
||||
import { AutoDiscovery, type AutoDiscoveryAction } from "./autodiscovery.ts";
|
||||
@@ -87,7 +87,12 @@ import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.t
|
||||
import { type MatrixScheduler } from "./scheduler.ts";
|
||||
import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts";
|
||||
import { type AuthDict } from "./interactive-auth.ts";
|
||||
import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
|
||||
import {
|
||||
type IMinimalEvent,
|
||||
type IRoomEvent,
|
||||
type IStateEvent,
|
||||
type ReceivedToDeviceMessage,
|
||||
} from "./sync-accumulator.ts";
|
||||
import type { EventTimelineSet } from "./models/event-timeline-set.ts";
|
||||
import * as ContentHelpers from "./content-helpers.ts";
|
||||
import {
|
||||
@@ -110,6 +115,7 @@ import {
|
||||
type IGuestAccessOpts,
|
||||
type IJoinRoomOpts,
|
||||
type INotificationsResponse,
|
||||
type InviteOpts,
|
||||
type IPaginateOpts,
|
||||
type IPresenceOpts,
|
||||
type IRedactOpts,
|
||||
@@ -207,7 +213,7 @@ import {
|
||||
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
|
||||
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
|
||||
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts";
|
||||
import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts";
|
||||
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
|
||||
import { IgnoredInvites } from "./models/invites-ignorer.ts";
|
||||
import { type UIARequest } from "./@types/uia.ts";
|
||||
import { type LocalNotificationSettings } from "./@types/local_notifications.ts";
|
||||
@@ -217,9 +223,9 @@ import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts";
|
||||
import {
|
||||
type CrossSigningKeyInfo,
|
||||
type CryptoApi,
|
||||
type CryptoCallbacks,
|
||||
CryptoEvent,
|
||||
type CryptoEventHandlerMap,
|
||||
type CryptoCallbacks,
|
||||
} from "./crypto-api/index.ts";
|
||||
import {
|
||||
type SecretStorageKeyDescription,
|
||||
@@ -360,7 +366,7 @@ export interface ICreateClientOpts {
|
||||
* to all requests with this client. Useful for application services which require
|
||||
* `?user_id=`.
|
||||
*/
|
||||
queryParams?: Record<string, string>;
|
||||
queryParams?: QueryDict;
|
||||
|
||||
/**
|
||||
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
|
||||
@@ -434,13 +440,13 @@ export interface ICreateClientOpts {
|
||||
|
||||
/**
|
||||
* If true, group calls will not establish media connectivity and only create the signaling events,
|
||||
* so that livekit media can be used in the application layert (js-sdk contains no livekit code).
|
||||
* so that livekit media can be used in the application layer (js-sdk contains no livekit code).
|
||||
*/
|
||||
useLivekitForGroupCalls?: boolean;
|
||||
|
||||
/**
|
||||
* A logger to associate with this MatrixClient.
|
||||
* Defaults to the built-in global logger.
|
||||
* Defaults to the built-in global logger; see {@link DebugLogger} for an alternative.
|
||||
*/
|
||||
logger?: Logger;
|
||||
}
|
||||
@@ -883,76 +889,6 @@ interface IWhoamiResponse {
|
||||
const EVENT_ID_PREFIX = "$";
|
||||
|
||||
export enum ClientEvent {
|
||||
Sync = "sync",
|
||||
Event = "event",
|
||||
ToDeviceEvent = "toDeviceEvent",
|
||||
AccountData = "accountData",
|
||||
Room = "Room",
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents =
|
||||
| RoomEvent.Name
|
||||
| RoomEvent.Redaction
|
||||
| RoomEvent.RedactionCancelled
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset;
|
||||
|
||||
type RoomStateEvents =
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker;
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
type RoomMemberEvents =
|
||||
| RoomMemberEvent.Name
|
||||
| RoomMemberEvent.Typing
|
||||
| RoomMemberEvent.PowerLevel
|
||||
| RoomMemberEvent.Membership;
|
||||
|
||||
type UserEvents =
|
||||
| UserEvent.AvatarUrl
|
||||
| UserEvent.DisplayName
|
||||
| UserEvent.Presence
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
export type EmittedEvents =
|
||||
| ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| CryptoEvents
|
||||
| MatrixEventEvents
|
||||
| RoomMemberEvents
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Outgoing
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
|
||||
export type ClientEventHandlerMap = {
|
||||
/**
|
||||
* Fires whenever the SDK's syncing state is updated. The state can be one of:
|
||||
* <ul>
|
||||
@@ -1035,13 +971,15 @@ export type ClientEventHandlerMap = {
|
||||
* trying to sync after stopClient has been called.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param state - An enum representing the syncing state. One of "PREPARED",
|
||||
* The payloads consits of the following 3 parameters:
|
||||
*
|
||||
* - state - An enum representing the syncing state. One of "PREPARED",
|
||||
* "SYNCING", "ERROR", "STOPPED".
|
||||
*
|
||||
* @param prevState - An enum representing the previous syncing state.
|
||||
* - prevState - An enum representing the previous syncing state.
|
||||
* One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
|
||||
*
|
||||
* @param data - Data about this transition.
|
||||
* - data - Data about this transition.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
@@ -1061,14 +999,14 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
|
||||
Sync = "sync",
|
||||
/**
|
||||
* Fires whenever the SDK receives a new event.
|
||||
* <p>
|
||||
* This is only fired for live events received via /sync - it is not fired for
|
||||
* events received over context, search, or pagination APIs.
|
||||
*
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* The payload is the matrix event which caused this event to fire.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("event", function(event){
|
||||
@@ -1076,10 +1014,10 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Event]: (event: MatrixEvent) => void;
|
||||
/**
|
||||
Event = "event",
|
||||
/** @deprecated Use {@link ReceivedToDeviceMessage}.
|
||||
* Fires whenever the SDK receives a new to-device event.
|
||||
* @param event - The matrix event which caused this event to fire.
|
||||
* The payload is the matrix event ({@link MatrixEvent}) which caused this event to fire.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("toDeviceEvent", function(event){
|
||||
@@ -1087,23 +1025,25 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
ToDeviceEvent = "toDeviceEvent",
|
||||
/**
|
||||
* Fires if a to-device event is received that cannot be decrypted.
|
||||
* Encrypted to-device events will (generally) use plain Olm encryption,
|
||||
* in which case decryption failures are fatal: the event will never be
|
||||
* decryptable, unlike Megolm encrypted events where the key may simply
|
||||
* arrive later.
|
||||
*
|
||||
* An undecryptable to-device event is therefore likely to indicate problems.
|
||||
*
|
||||
* @param event - The undecyptable to-device event
|
||||
* Fires whenever the SDK receives a new (potentially decrypted) to-device message.
|
||||
* The payload is the to-device message and the encryption info for that message ({@link ReceivedToDeviceMessage}).
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("receivedToDeviceMessage", function(payload){
|
||||
* const { message, encryptionInfo } = payload;
|
||||
* var claimed_sender = encryptionInfo ? encryptionInfo.sender : message.sender;
|
||||
* var isVerified = encryptionInfo ? encryptionInfo.verified : false;
|
||||
* var type = message.type;
|
||||
* });
|
||||
*/
|
||||
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
ReceivedToDeviceMessage = "receivedToDeviceMessage",
|
||||
/**
|
||||
* Fires whenever new user-scoped account_data is added.
|
||||
* @param event - The event describing the account_data just added
|
||||
* @param event - The previous account data, if known.
|
||||
* The payload is a pair of event ({@link MatrixEvent}) describing the account_data just added, and the previous event, if known:
|
||||
* - event: The event describing the account_data just added
|
||||
* - oldEvent: The previous account data, if known.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("accountData", function(event, oldEvent){
|
||||
@@ -1111,12 +1051,13 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
|
||||
AccountData = "accountData",
|
||||
/**
|
||||
* Fires whenever a new Room is added. This will fire when you are invited to a
|
||||
* room, as well as when you join a room. <strong>This event is experimental and
|
||||
* may change.</strong>
|
||||
* @param room - The newly created, fully populated room.
|
||||
*
|
||||
* The payload is the newly created room, fully populated.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("Room", function(room){
|
||||
@@ -1124,11 +1065,11 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.Room]: (room: Room) => void;
|
||||
Room = "Room",
|
||||
/**
|
||||
* Fires whenever a Room is removed. This will fire when you forget a room.
|
||||
* <strong>This event is experimental and may change.</strong>
|
||||
* @param roomId - The deleted room ID.
|
||||
* The payload is the roomId of the deleted room.
|
||||
* @example
|
||||
* ```
|
||||
* matrixClient.on("deleteRoom", function(roomId){
|
||||
@@ -1136,13 +1077,82 @@ export type ClientEventHandlerMap = {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
[ClientEvent.DeleteRoom]: (roomId: string) => void;
|
||||
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
/**
|
||||
* Fires when the client .well-known info is fetched.
|
||||
*
|
||||
* @param data - The JSON object returned by the server
|
||||
* The payload is the JSON object (see {@link IClientWellKnown}) returned by the server
|
||||
*/
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
|
||||
type RoomEvents =
|
||||
| RoomEvent.Name
|
||||
| RoomEvent.Redaction
|
||||
| RoomEvent.RedactionCancelled
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset;
|
||||
|
||||
type RoomStateEvents =
|
||||
| RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker;
|
||||
|
||||
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
|
||||
|
||||
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
|
||||
|
||||
type RoomMemberEvents =
|
||||
| RoomMemberEvent.Name
|
||||
| RoomMemberEvent.Typing
|
||||
| RoomMemberEvent.PowerLevel
|
||||
| RoomMemberEvent.Membership;
|
||||
|
||||
type UserEvents =
|
||||
| UserEvent.AvatarUrl
|
||||
| UserEvent.DisplayName
|
||||
| UserEvent.Presence
|
||||
| UserEvent.CurrentlyActive
|
||||
| UserEvent.LastPresenceTs;
|
||||
|
||||
export type EmittedEvents =
|
||||
| ClientEvent
|
||||
| RoomEvents
|
||||
| RoomStateEvents
|
||||
| CryptoEvents
|
||||
| MatrixEventEvents
|
||||
| RoomMemberEvents
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Outgoing
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
|
||||
export type ClientEventHandlerMap = {
|
||||
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
|
||||
[ClientEvent.Event]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void;
|
||||
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
|
||||
[ClientEvent.Room]: (room: Room) => void;
|
||||
[ClientEvent.DeleteRoom]: (roomId: string) => void;
|
||||
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
|
||||
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
|
||||
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
|
||||
@@ -1341,9 +1351,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// NB. We initialise MatrixRTC whether we have call support or not: this is just
|
||||
// the underlying session management and doesn't use any actual media capabilities
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this);
|
||||
this.matrixRTC = new MatrixRTCSessionManager(this.logger, this);
|
||||
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.http);
|
||||
this.serverCapabilitiesService = new ServerCapabilities(this.logger, this.http);
|
||||
|
||||
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
|
||||
|
||||
@@ -1365,7 +1375,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
this.roomNameGenerator = opts.roomNameGenerator;
|
||||
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
|
||||
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this, this.logger);
|
||||
|
||||
// The SDK doesn't really provide a clean way for events to recalculate the push
|
||||
// actions for themselves, so we have to kinda help them out when they are encrypted.
|
||||
@@ -1482,6 +1492,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
return this.canResetTimelineCallback(roomId);
|
||||
},
|
||||
logger: this.logger.getChild("sync"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1527,9 +1538,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
/**
|
||||
* Clear any data out of the persistent stores used by the client.
|
||||
*
|
||||
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
|
||||
* @returns Promise which resolves when the stores have been cleared.
|
||||
*/
|
||||
public clearStores(): Promise<void> {
|
||||
public clearStores(
|
||||
args: {
|
||||
cryptoDatabasePrefix?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (this.clientRunning) {
|
||||
throw new Error("Cannot clear stores while client is running");
|
||||
}
|
||||
@@ -1552,8 +1568,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return;
|
||||
}
|
||||
for (const dbname of [
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
|
||||
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
|
||||
]) {
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
this.logger.info(`Removing IndexedDB instance ${dbname}`);
|
||||
@@ -1901,6 +1917,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* ensuring that only one `MatrixClient` issue is instantiated at a time.
|
||||
*
|
||||
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
|
||||
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
|
||||
* Unused if useIndexedDB is 'false'.
|
||||
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
|
||||
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
|
||||
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
|
||||
@@ -1914,6 +1932,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public async initRustCrypto(
|
||||
args: {
|
||||
useIndexedDB?: boolean;
|
||||
cryptoDatabasePrefix?: string;
|
||||
storageKey?: Uint8Array;
|
||||
storagePassword?: string;
|
||||
} = {},
|
||||
@@ -1950,7 +1969,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
deviceId: deviceId,
|
||||
secretStorage: this.secretStorage,
|
||||
cryptoCallbacks: this.cryptoCallbacks,
|
||||
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
|
||||
storePrefix: args.useIndexedDB === false ? null : (args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX),
|
||||
storeKey: args.storageKey,
|
||||
storePassphrase: args.storagePassword,
|
||||
|
||||
@@ -2176,7 +2195,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
): Promise<EmptyObject> {
|
||||
// If the sync loop is not running, fall back to setAccountDataRaw.
|
||||
if (!this.clientRunning) {
|
||||
logger.warn(
|
||||
this.logger.warn(
|
||||
"Calling `setAccountData` before the client is started: `getAccountData` may return inconsistent results.",
|
||||
);
|
||||
return await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
|
||||
@@ -2191,7 +2210,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (existingData && deepCompare(existingData.event.content, content)) return {};
|
||||
|
||||
// Create a promise which will resolve when the update is received
|
||||
const updatedDefer = defer<void>();
|
||||
const updatedResolvers = Promise.withResolvers<void>();
|
||||
function accountDataListener(event: MatrixEvent): void {
|
||||
// Note that we cannot safely check that the content matches what we expected, because there is a race:
|
||||
// * We set the new content
|
||||
@@ -2203,13 +2222,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
//
|
||||
// Anyway, what we *shouldn't* do is get stuck in a loop. I think the best we can do is check that the event
|
||||
// type matches.
|
||||
if (event.getType() === eventType) updatedDefer.resolve();
|
||||
if (event.getType() === eventType) updatedResolvers.resolve();
|
||||
}
|
||||
this.addListener(ClientEvent.AccountData, accountDataListener);
|
||||
|
||||
try {
|
||||
const result = await retryNetworkOperation(5, () => this.setAccountDataRaw(eventType, content));
|
||||
await updatedDefer.promise;
|
||||
await updatedResolvers.promise;
|
||||
return result;
|
||||
} finally {
|
||||
this.removeListener(ClientEvent.AccountData, accountDataListener);
|
||||
@@ -2336,12 +2355,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
|
||||
if (opts.syncRoom === undefined) {
|
||||
opts.syncRoom = true;
|
||||
}
|
||||
|
||||
const room = this.getRoom(roomIdOrAlias);
|
||||
if (room?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return room;
|
||||
const roomMember = room?.getMember(this.getSafeUserId());
|
||||
const preJoinMembership = roomMember?.membership;
|
||||
|
||||
// If we were invited to the room, the ID of the user that sent the invite. Otherwise, `null`.
|
||||
const inviter =
|
||||
preJoinMembership == KnownMembership.Invite ? (roomMember?.events.member?.getSender() ?? null) : null;
|
||||
|
||||
this.logger.debug(
|
||||
`joinRoom[${roomIdOrAlias}]: preJoinMembership=${preJoinMembership}, inviter=${inviter}, opts=${JSON.stringify(opts)}`,
|
||||
);
|
||||
if (preJoinMembership == KnownMembership.Join) return room!;
|
||||
|
||||
let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
|
||||
|
||||
@@ -2368,6 +2393,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
|
||||
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
|
||||
}
|
||||
|
||||
// In case we were originally given an alias, check the room cache again
|
||||
// with the resolved ID - this method is supposed to no-op if we already
|
||||
// were in the room, after all.
|
||||
@@ -2375,12 +2404,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (resolvedRoom?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return resolvedRoom;
|
||||
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const syncRoom = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
// return syncApi.syncRoom(room);
|
||||
}
|
||||
return syncRoom;
|
||||
return syncApi.createRoom(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3731,12 +3755,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* @param reason - Optional.
|
||||
* @returns Promise which resolves: `{}` an empty object.
|
||||
* @returns Rejects: with an error response.
|
||||
* Send an invite to the given user to join the given room.
|
||||
*
|
||||
* @param roomId - The ID of the room to which the user should be invited.
|
||||
* @param userId - The ID of the user that should be invited.
|
||||
* @param opts - Optional reason object. For backwards compatibility, a string is also accepted, and will be interpreted as a reason.
|
||||
*
|
||||
* @returns An empty object.
|
||||
*/
|
||||
public invite(roomId: string, userId: string, reason?: string): Promise<EmptyObject> {
|
||||
return this.membershipChange(roomId, userId, KnownMembership.Invite, reason);
|
||||
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
|
||||
if (typeof opts != "object") {
|
||||
opts = { reason: opts };
|
||||
}
|
||||
|
||||
if (opts.shareEncryptedHistory) {
|
||||
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
|
||||
}
|
||||
|
||||
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5173,24 +5209,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
} else if (!hasDontNotifyRule) {
|
||||
// Remove the existing one before setting the mute push rule
|
||||
// This is a workaround to SYN-590 (Push rule update fails)
|
||||
const deferred = utils.defer();
|
||||
const doneResolvers = Promise.withResolvers<void>();
|
||||
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
|
||||
.then(() => {
|
||||
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
})
|
||||
.then(() => {
|
||||
deferred.resolve();
|
||||
doneResolvers.resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
deferred.reject(err);
|
||||
doneResolvers.reject(err);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
deferred.reject(err);
|
||||
doneResolvers.reject(err);
|
||||
});
|
||||
|
||||
promise = deferred.promise;
|
||||
promise = doneResolvers.promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6811,9 +6847,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param opts - options object
|
||||
*
|
||||
* @returns Promise which resolves to response object, as
|
||||
* determined by this.opts.onlyData, opts.rawResponse, and
|
||||
* opts.onlyContentUri. Rejects with an error (usually a MatrixError).
|
||||
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
|
||||
*/
|
||||
public uploadContent(file: FileType, opts?: UploadOpts): Promise<UploadResponse> {
|
||||
return this.http.uploadContent(file, opts);
|
||||
@@ -7295,7 +7329,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public setPushRules(rules: IPushRules): void {
|
||||
// Fix-up defaults, if applicable.
|
||||
this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
|
||||
this.pushRules = PushProcessor.rewriteDefaultRules(this.logger, rules, this.getUserId()!);
|
||||
// Pre-calculate any necessary caches.
|
||||
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
|
||||
}
|
||||
@@ -7942,6 +7976,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.http.authedRequest(Method.Put, path, undefined, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will encrypt the payload for all devices in the list and will queue it.
|
||||
* The type of the sent to-device message will be `m.room.encrypted`.
|
||||
* @param eventType - The type of event to send
|
||||
* @param devices - The list of devices to send the event to.
|
||||
* @param payload - The payload to send. This will be encrypted.
|
||||
* @returns Promise which resolves once queued there is no error feedback when sending fails.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("Cannot encrypt to device event, your client does not support encryption.");
|
||||
}
|
||||
const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload);
|
||||
|
||||
// TODO The batch mechanism removes all possibility to get error feedbacks..
|
||||
// We might want instead to do the API call directly and pass the errors back.
|
||||
await this.queueToDevice(batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends events directly to specific devices using Matrix's to-device
|
||||
* messaging system. The batch will be split up into appropriately sized
|
||||
@@ -8343,21 +8400,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC issuer responsible for authentication on this server, if any
|
||||
* @returns Resolves: A promise of an object containing the OIDC issuer if configured
|
||||
* @returns Rejects: when the request fails (module:http-api.MatrixError)
|
||||
* @experimental - part of MSC2965
|
||||
* @deprecated in favour of getAuthMetadata
|
||||
*/
|
||||
public async getAuthIssuer(): Promise<{
|
||||
issuer: string;
|
||||
}> {
|
||||
return this.http.request(Method.Get, "/auth_issuer", undefined, undefined, {
|
||||
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and validate delegated auth configuration
|
||||
* - delegated auth issuer openid-configuration is reachable
|
||||
@@ -8377,7 +8419,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
|
||||
const { issuer } = await this.getAuthIssuer();
|
||||
// Fall back to older variant of MSC2965
|
||||
const { issuer } = await this.http.request<{
|
||||
issuer: string;
|
||||
}>(Method.Get, "/auth_issuer", undefined, undefined, {
|
||||
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
|
||||
});
|
||||
return discoverAndValidateOIDCIssuerWellKnown(issuer);
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
|
||||
import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts";
|
||||
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
|
||||
import { type Room } from "../models/room.ts";
|
||||
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
|
||||
@@ -79,6 +79,19 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
||||
* @returns a promise which resolves once the keys have been imported
|
||||
*/
|
||||
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Having accepted an invite for the given room from the given user, attempt to
|
||||
* find information about a room key bundle and, if found, download the
|
||||
* bundle and import the room keys, as per {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268}.
|
||||
*
|
||||
* @param roomId - The room we were invited to, for which we want to check if a room
|
||||
* key bundle was received.
|
||||
*
|
||||
* @param inviter - The user who invited us to the room and is expected to have
|
||||
* sent the room key bundle.
|
||||
*/
|
||||
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The methods which crypto implementations should expose to the Sync api
|
||||
@@ -96,9 +109,11 @@ export interface SyncCryptoCallbacks {
|
||||
* messages, rather than the results of any decryption attempts.
|
||||
*
|
||||
* @param events - the received to-device messages
|
||||
* @returns A list of preprocessed to-device messages.
|
||||
* @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or
|
||||
* failed to decrypt, and so will be omitted from the output list.
|
||||
*
|
||||
*/
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
|
||||
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]>;
|
||||
|
||||
/**
|
||||
* Called by the /sync loop when one time key counts and unused fallback key details are received.
|
||||
|
||||
@@ -189,12 +189,14 @@ export type MakeTopicContent = (topic: string | null | undefined, htmlTopic?: st
|
||||
|
||||
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
|
||||
const renderings = [];
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
// Put HTML first because clients will render the first type in
|
||||
// the array that they understand
|
||||
if (isProvided(htmlTopic)) {
|
||||
renderings.push({ body: htmlTopic, mimetype: "text/html" });
|
||||
}
|
||||
if (isProvided(topic)) {
|
||||
renderings.push({ body: topic, mimetype: "text/plain" });
|
||||
}
|
||||
return { topic, [M_TOPIC.name]: renderings };
|
||||
};
|
||||
|
||||
|
||||
+41
-107
@@ -468,16 +468,6 @@ export interface CryptoApi {
|
||||
*/
|
||||
getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[];
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room id
|
||||
*
|
||||
* @param roomId - the room to use for verification
|
||||
*
|
||||
* @returns the VerificationRequest that is in progress, if any
|
||||
* @deprecated prefer `userId` parameter variant.
|
||||
*/
|
||||
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
|
||||
|
||||
/**
|
||||
* Finds a DM verification request that is already in progress for the given room and user.
|
||||
*
|
||||
@@ -545,18 +535,6 @@ export interface CryptoApi {
|
||||
*/
|
||||
getSessionBackupPrivateKey(): Promise<Uint8Array | null>;
|
||||
|
||||
/**
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
* This should be called if the client has received the key from another device via secret sharing (gossiping).
|
||||
* It is the responsability of the caller to check that the decryption key is valid for the current backup version.
|
||||
*
|
||||
* @param key - the backup decryption key
|
||||
*
|
||||
* @deprecated prefer the variant with a `version` parameter.
|
||||
*/
|
||||
storeSessionBackupPrivateKey(key: Uint8Array): Promise<void>;
|
||||
|
||||
/**
|
||||
* Store the backup decryption key.
|
||||
*
|
||||
@@ -729,6 +707,20 @@ export interface CryptoApi {
|
||||
* @param secrets - The secrets bundle received from the other device
|
||||
*/
|
||||
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Room key history sharing (MSC4268)
|
||||
//
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Share any shareable E2EE history in the given room with the given recipient,
|
||||
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
@@ -787,45 +779,6 @@ export enum DecryptionFailureCode {
|
||||
|
||||
/** Unknown or unclassified error. */
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
MEGOLM_BAD_ROOM = "MEGOLM_BAD_ROOM",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
MEGOLM_MISSING_FIELDS = "MEGOLM_MISSING_FIELDS",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_DECRYPT_GROUP_MESSAGE_ERROR = "OLM_DECRYPT_GROUP_MESSAGE_ERROR",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_ENCRYPTED_MESSAGE = "OLM_BAD_ENCRYPTED_MESSAGE",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_RECIPIENT = "OLM_BAD_RECIPIENT",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_RECIPIENT_KEY = "OLM_BAD_RECIPIENT_KEY",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_ROOM = "OLM_BAD_ROOM",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_SENDER_CHECK_FAILED = "OLM_BAD_SENDER_CHECK_FAILED",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_SENDER = "OLM_BAD_SENDER",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_FORWARDED_MESSAGE = "OLM_FORWARDED_MESSAGE",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_MISSING_CIPHERTEXT = "OLM_MISSING_CIPHERTEXT",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_NOT_INCLUDED_IN_RECIPIENTS = "OLM_NOT_INCLUDED_IN_RECIPIENTS",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
UNKNOWN_ENCRYPTION_ALGORITHM = "UNKNOWN_ENCRYPTION_ALGORITHM",
|
||||
}
|
||||
|
||||
/** Base {@link DeviceIsolationMode} kind. */
|
||||
@@ -1090,8 +1043,6 @@ export type ImportRoomKeyProgressData = ImportRoomKeyFetchProgress | ImportRoomK
|
||||
export interface ImportRoomKeysOpts {
|
||||
/** Reports ongoing progress of the import process. Can be used for feedback. */
|
||||
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
|
||||
/** @deprecated the rust SDK will always such imported keys as untrusted */
|
||||
untrusted?: boolean;
|
||||
/** @deprecated not useful externally */
|
||||
source?: string;
|
||||
}
|
||||
@@ -1179,13 +1130,6 @@ export interface CryptoCallbacks {
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
|
||||
/** @deprecated: unused with the Rust crypto stack. */
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
/** @deprecated: unused with the Rust crypto stack. */
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
/** @deprecated: unused with the Rust crypto stack. */
|
||||
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Called by {@link CryptoApi.bootstrapSecretStorage} when a new default secret storage key is created.
|
||||
*
|
||||
@@ -1197,24 +1141,6 @@ export interface CryptoCallbacks {
|
||||
* @param key - private key to store
|
||||
*/
|
||||
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
|
||||
|
||||
/** @deprecated: unused with the Rust crypto stack. */
|
||||
onSecretRequested?: (
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
requestId: string,
|
||||
secretName: string,
|
||||
deviceTrust: DeviceVerificationStatus,
|
||||
) => Promise<string | undefined>;
|
||||
|
||||
/** @deprecated: unused with the Rust crypto stack. */
|
||||
getDehydrationKey?: (
|
||||
keyInfo: SecretStorageKeyDescription,
|
||||
checkFunc: (key: Uint8Array) => void,
|
||||
) => Promise<Uint8Array>;
|
||||
|
||||
/** @deprecated: unused with the Rust crypto stack. */
|
||||
getBackupKey?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1229,13 +1155,6 @@ export interface CreateSecretStorageOpts {
|
||||
*/
|
||||
createSecretStorageKey?: () => Promise<GeneratedSecretStorageKey>;
|
||||
|
||||
/**
|
||||
* The current key backup object. If passed,
|
||||
* the passphrase and recovery key from this backup will be used.
|
||||
* @deprecated Not used by the Rust crypto stack.
|
||||
*/
|
||||
keyBackupInfo?: KeyBackupInfo;
|
||||
|
||||
/**
|
||||
* If true, a new key backup version will be
|
||||
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
|
||||
@@ -1247,18 +1166,6 @@ export interface CreateSecretStorageOpts {
|
||||
* Reset even if keys already exist.
|
||||
*/
|
||||
setupNewSecretStorage?: boolean;
|
||||
|
||||
/**
|
||||
* Function called to get the user's current key backup passphrase.
|
||||
*
|
||||
* Should return a promise that resolves with a Uint8Array
|
||||
* containing the key, or rejects if the key cannot be obtained.
|
||||
*
|
||||
* Only used when the client has existing key backup, but no secret storage.
|
||||
*
|
||||
* @deprecated Not used by the Rust crypto stack.
|
||||
*/
|
||||
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/** Types of cross-signing key */
|
||||
@@ -1355,6 +1262,12 @@ export enum EventShieldReason {
|
||||
* The sender was previously verified but changed their identity.
|
||||
*/
|
||||
VERIFICATION_VIOLATION,
|
||||
|
||||
/**
|
||||
* The `sender` field on the event does not match the owner of the device
|
||||
* that established the Megolm session.
|
||||
*/
|
||||
MISMATCHED_SENDER,
|
||||
}
|
||||
|
||||
/** The result of a call to {@link CryptoApi.getOwnDeviceKeys} */
|
||||
@@ -1365,6 +1278,27 @@ export interface OwnDeviceKeys {
|
||||
curve25519: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the encryption of a successfully decrypted to-device message.
|
||||
*/
|
||||
export interface OlmEncryptionInfo {
|
||||
/** The user ID of the event sender, note this is untrusted data unless `isVerified` is true **/
|
||||
sender: string;
|
||||
/**
|
||||
* The device ID of the device that sent us the event.
|
||||
* Note this is untrusted data unless {@link senderVerified} is true.
|
||||
* If the device ID is not known, this will be `null`.
|
||||
**/
|
||||
senderDevice?: string;
|
||||
/** The sender device's public Curve25519 key, base64 encoded **/
|
||||
senderCurve25519KeyBase64: string;
|
||||
/**
|
||||
* If true, this message is guaranteed to be authentic as it is coming from a device belonging to a user that we have verified.
|
||||
* This is the state at the time of decryption (the user could be verified later).
|
||||
*/
|
||||
senderVerified: boolean;
|
||||
}
|
||||
|
||||
export * from "./verification.ts";
|
||||
export type * from "./keybackup.ts";
|
||||
export * from "./recovery-key.ts";
|
||||
|
||||
@@ -114,25 +114,6 @@ export interface VerificationRequest
|
||||
*/
|
||||
cancel(params?: { reason?: string; code?: string }): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a {@link Verifier} to do this verification via a particular method.
|
||||
*
|
||||
* If a verifier has already been created for this request, returns that verifier.
|
||||
*
|
||||
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Verifier.verify} on the
|
||||
* returned verifier.
|
||||
*
|
||||
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
|
||||
*
|
||||
* @param method - the name of the verification method to use.
|
||||
* @param targetDevice - details of where to send the request to.
|
||||
*
|
||||
* @returns The verifier which will do the actual verification.
|
||||
*
|
||||
* @deprecated Use {@link VerificationRequest#startVerification} instead.
|
||||
*/
|
||||
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
|
||||
|
||||
/**
|
||||
* Send an `m.key.verification.start` event to start verification via a particular method.
|
||||
*
|
||||
@@ -163,15 +144,6 @@ export interface VerificationRequest
|
||||
*/
|
||||
get verifier(): Verifier | undefined;
|
||||
|
||||
/**
|
||||
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
|
||||
*
|
||||
* @deprecated Not supported in Rust Crypto. Use {@link VerificationRequest#generateQRCode} instead.
|
||||
*/
|
||||
getQRCodeBytes(): Uint8ClampedArray | undefined;
|
||||
|
||||
/**
|
||||
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
|
||||
@@ -204,9 +204,12 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
);
|
||||
|
||||
try {
|
||||
if (!(globalThis.localStorage instanceof Storage)) {
|
||||
throw new Error("localStorage is not available");
|
||||
}
|
||||
return new LocalStorageCryptoStore(globalThis.localStorage);
|
||||
} catch (e) {
|
||||
logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
|
||||
return new MemoryCryptoStore();
|
||||
}
|
||||
})
|
||||
|
||||
+108
-70
@@ -28,6 +28,8 @@ import {
|
||||
type WidgetApiAction,
|
||||
type IWidgetApiResponse,
|
||||
type IWidgetApiResponseData,
|
||||
type IUpdateStateToWidgetActionRequest,
|
||||
UnstableApiVersion,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { MatrixEvent, type IEvent, type IContent, EventStatus } from "./models/event.ts";
|
||||
@@ -62,17 +64,6 @@ interface IStateEventRequest {
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export interface OlmDevice {
|
||||
/**
|
||||
* The user ID of the device owner.
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The device ID of the device.
|
||||
*/
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface ICapabilities {
|
||||
/**
|
||||
* Event types that this client expects to send.
|
||||
@@ -146,6 +137,7 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: ()
|
||||
export class RoomWidgetClient extends MatrixClient {
|
||||
private room?: Room;
|
||||
private readonly widgetApiReady: Promise<void>;
|
||||
private readonly roomStateSynced: Promise<void>;
|
||||
private lifecycle?: AbortController;
|
||||
private syncState: SyncState | null = null;
|
||||
|
||||
@@ -199,6 +191,11 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
};
|
||||
|
||||
this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
|
||||
this.roomStateSynced = capabilities.receiveState?.length
|
||||
? new Promise<void>((resolve) =>
|
||||
this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve),
|
||||
)
|
||||
: Promise.resolve();
|
||||
|
||||
// Request capabilities for the functionality this client needs to support
|
||||
if (
|
||||
@@ -251,6 +248,7 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
|
||||
|
||||
// Open communication with the host
|
||||
widgetApi.start();
|
||||
@@ -262,6 +260,10 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
if (sendContentLoaded) widgetApi.sendContentLoaded();
|
||||
}
|
||||
|
||||
public async supportUpdateState(): Promise<boolean> {
|
||||
return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
|
||||
}
|
||||
|
||||
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
|
||||
this.lifecycle = new AbortController();
|
||||
|
||||
@@ -286,27 +288,34 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
|
||||
await this.widgetApiReady;
|
||||
|
||||
// Backfill the requested events
|
||||
// We only get the most recent event for every type + state key combo,
|
||||
// so it doesn't really matter what order we inject them in
|
||||
await Promise.all(
|
||||
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
|
||||
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
|
||||
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
|
||||
// sync room state:
|
||||
if (await this.supportUpdateState()) {
|
||||
// This will resolve once the client driver has sent us all the allowed room state.
|
||||
await this.roomStateSynced;
|
||||
} else {
|
||||
// Backfill the requested events
|
||||
// We only get the most recent event for every type + state key combo,
|
||||
// so it doesn't really matter what order we inject them in
|
||||
await Promise.all(
|
||||
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
|
||||
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [
|
||||
this.roomId,
|
||||
]);
|
||||
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
|
||||
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
|
||||
// -> state events in `timelineEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
|
||||
} else {
|
||||
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
|
||||
}
|
||||
events.forEach((event) => {
|
||||
this.emit(ClientEvent.Event, event);
|
||||
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
});
|
||||
}) ?? [],
|
||||
);
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
// Passing events as `stateAfterEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
|
||||
} else {
|
||||
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
|
||||
}
|
||||
events.forEach((event) => {
|
||||
this.emit(ClientEvent.Event, event);
|
||||
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
});
|
||||
}) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.clientWellKnownPollPeriod !== undefined) {
|
||||
this.clientWellKnownIntervalID = setInterval(() => {
|
||||
@@ -314,9 +323,8 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
}, 1000 * opts.clientWellKnownPollPeriod);
|
||||
this.fetchClientWellKnown();
|
||||
}
|
||||
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info("Finished backfilling events");
|
||||
logger.info("Finished initial sync");
|
||||
|
||||
this.matrixRTC.start();
|
||||
|
||||
@@ -327,6 +335,7 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
public stopClient(): void {
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
|
||||
|
||||
super.stopClient();
|
||||
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
|
||||
@@ -464,6 +473,25 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* by {@link MatrixClient.encryptAndSendToDevice}.
|
||||
*/
|
||||
public async encryptAndSendToDevice(
|
||||
eventType: string,
|
||||
devices: { userId: string; deviceId: string }[],
|
||||
payload: ToDevicePayload,
|
||||
): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of devices) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
|
||||
await this.widgetApi
|
||||
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
|
||||
@@ -495,18 +523,6 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise<void> {
|
||||
// map: user Id → device Id → payload
|
||||
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
|
||||
for (const { userId, deviceId } of userDeviceInfoArr) {
|
||||
contentMap.getOrCreate(userId).set(deviceId, payload);
|
||||
}
|
||||
|
||||
await this.widgetApi
|
||||
.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap))
|
||||
.catch(timeoutToConnectionError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
|
||||
*
|
||||
@@ -604,36 +620,28 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
// Only inject once we have update the txId
|
||||
await this.updateTxId(event);
|
||||
|
||||
// The widget API does not tell us whether a state event came from `state_after` or not so we assume legacy behaviour for now.
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
// The code will want to be something like:
|
||||
// ```
|
||||
// if (!params.addToTimeline && !params.addToState) {
|
||||
// // Passing undefined for `stateAfterEventList` makes `injectRoomEvents` run in "legacy mode"
|
||||
// // -> state events part of the `timelineEventList` parameter will update the state.
|
||||
// this.injectRoomEvents(this.room!, [], undefined, [event]);
|
||||
// } else {
|
||||
// this.injectRoomEvents(this.room!, undefined, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
|
||||
// }
|
||||
// ```
|
||||
|
||||
// Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode
|
||||
// -> state events in `timelineEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
|
||||
if (await this.supportUpdateState()) {
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
|
||||
} else {
|
||||
// Passing undefined for `stateAfterEventList` will make `injectRoomEvents` run in legacy mode
|
||||
// -> state events in `timelineEventList` will update the state.
|
||||
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
|
||||
}
|
||||
} else {
|
||||
// The code will want to be something like:
|
||||
// ```
|
||||
// if (!params.addToTimeline && !params.addToState) {
|
||||
// this.injectRoomEvents(this.room!, [], [event]);
|
||||
// } else {
|
||||
// this.injectRoomEvents(this.room!, params.addToState ? [event] : [], params.addToTimeline ? [event] : []);
|
||||
// }
|
||||
// ```
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [], [event]); // Sliding Sync
|
||||
// Sliding Sync
|
||||
if (await this.supportUpdateState()) {
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
|
||||
} else {
|
||||
logger.error(
|
||||
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ClientEvent.Event, event);
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
logger.info(`Received event ${event.getId()} ${event.getType()}`);
|
||||
} else {
|
||||
const { event_id: eventId, room_id: roomId } = ev.detail.data;
|
||||
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
|
||||
@@ -658,6 +666,36 @@ export class RoomWidgetClient extends MatrixClient {
|
||||
await this.ack(ev);
|
||||
};
|
||||
|
||||
private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (!(await this.supportUpdateState())) {
|
||||
logger.warn(
|
||||
"received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'",
|
||||
);
|
||||
}
|
||||
for (const rawEvent of ev.detail.data.state) {
|
||||
// Verify the room ID matches, since it's possible for the client to
|
||||
// send us state updates from other rooms if this widget is always
|
||||
// on screen
|
||||
if (rawEvent.room_id === this.roomId) {
|
||||
const event = new MatrixEvent(rawEvent as Partial<IEvent>);
|
||||
|
||||
if (this.syncApi instanceof SyncApi) {
|
||||
await this.syncApi.injectRoomEvents(this.room!, undefined, [event]);
|
||||
} else {
|
||||
// Sliding Sync
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [event]);
|
||||
}
|
||||
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
|
||||
} else {
|
||||
const { event_id: eventId, room_id: roomId } = ev.detail.data;
|
||||
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.ack(ev);
|
||||
};
|
||||
|
||||
private async watchTurnServers(): Promise<void> {
|
||||
const servers = this.widgetApi.getTurnServers();
|
||||
const onClientStopped = (): void => {
|
||||
|
||||
@@ -25,9 +25,6 @@ export interface MapperOpts {
|
||||
preventReEmit?: boolean;
|
||||
// decrypt event proactively
|
||||
decrypt?: boolean;
|
||||
|
||||
/** @deprecated no longer used */
|
||||
toDevice?: boolean;
|
||||
}
|
||||
|
||||
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user