Compare commits

..

271 Commits

Author SHA1 Message Date
RiotRobot 25555ec431 v40.0.0 2026-01-13 14:28:14 +00:00
RiotRobot 455b614008 v40.0.0-rc.0 2026-01-06 14:01:30 +00:00
Will Hunt 93f4f40202 Implement MSC4387: M_SAFETY error (#5107)
* Implement MatrixSafetyError

* Mention safety error on associated functions.

* fix import

* move error

* cleanup and add test

* wording

* fix test

* fixup error

* Fix exp
2026-01-06 13:04:52 +00:00
Richard van der Hoff aeade9ce58 Remove unused property MatrixEvent.untrusted (#5118)
* Remove unused property MatrixEvent.untrusted

This was never set to anything other than `false`. I think it is a hangover
from pre-rust-sdk.

* Remove call to redundant `isKeySourceUntrusted`

`isKeySourceUntrusted` always returns false so no point calling it

* Remove dangling assignments to MatrixEvent.untrusted
2026-01-06 11:23:13 +00:00
Timo 4b89fb23c5 MatrixRTC Pseudonymous livekit identities (#5110)
* deprecate membershipID -> memberId & memberId -> stateKey in membership
manager

The membership manager used the memberId label for the stateKey. But
only the StickymembershipManager really has a configurable memberId.

* participantId -> callMembershipIdentityParts

The participantId is a termonology from livekit. We do not want it in
here! We want the js-sdk to be mostly transport agnostic. We do the
transition from the identity parts to the acutal livekit identity in
Element call (`sha256(userId+deviceId+memberId)`)

* update tests

* Expose `kind` to decide if we use the hashed or non hashed livekit
participants.

* expose delayId from the matrixRTCSession for delayed event delegation.

* rename if to mapKey

* backandId computation as part of the js-sdk

* review valere

* valr + timo keysWithoutMatchingRTCMembership

* fix legacy encryption manager

* fix doc issue

* fix doc

* fix imports

* Encryption Manager needs own rtcBackendIdentity to use

The encryption manager needs to signal our own key fast, cannot wait for remote echo of rtc membership. So it needs to be able to compute the rtcBackendIdentity

* fix test

* Remove double `useHashedRtcBackendIdentity` assignment. rename
variables.

* little improvements This stops the usage from the matrix event outside
the CallMemerbship constructor.

* fix logger import

* Add back deprecated API for compat

* Make change to CallMembership constructor backward compatible

* more backward compatible

---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
2026-01-05 18:40:11 +00:00
Robin 174439c2f0 Make MatrixRTC encryption key types narrower for TS 5.9 compatibility (#5117)
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-9.html#libdts-changes

TypeScript 5.9 changes some things about the ArrayBuffer type and makes a number of DOM types, including the subtle crypto APIs, require a narrower buffer type as their input. For example if you wanted to use crypto.subtle.importKey to convert a MatrixRTC encryption key buffer given by matrix-js-sdk to a CryptoKey, you would run into a type error with TS 5.9. Specifying the type parameter of Uint8Array everywhere around the MatrixRTC files fixes this breakage.
2026-01-05 17:42:21 +00:00
Richard van der Hoff 43f3e10f05 Improve documentation on rawDisplayName (#5114)
... since it's not actually the raw displayname, at all.
2025-12-18 14:57:14 +00:00
Andy Balaam 97fcdb2830 Make the enableEncryptedStateEvents property on MatrixClient public (#5113)
* Make the enableEncryptedStateEvents property on MatrixClient public

* fixup! Make the enableEncryptedStateEvents property on MatrixClient public

tsdoc for enableEncryptedStateEvents

* fixup! Make the enableEncryptedStateEvents property on MatrixClient public

Improve the description of enableEncryptedStateEvents
2025-12-18 13:57:20 +00:00
Andy Balaam 31e2d8eb20 Re-check outgoing requests after processing them (#5109)
... in case any new requests have been added during processing.
Fixes https://github.com/element-hq/element-web/issues/30988
2025-12-17 12:32:04 +00:00
Richard van der Hoff 633a5a8848 Mark forwardingCurve25519KeyChain as deprecated (#5111)
The Rust SDK always populates this as an empty array, so we may as well get rid
of it.
2025-12-16 14:05:26 +00:00
Richard van der Hoff a5086a09b9 Mark IEventDecryptionResult as deprecated (#5112)
This is supposed to be js-sdk-internal
2025-12-16 14:05:16 +00:00
RiotRobot c251be9ae5 Merge branch 'master' into develop 2025-12-16 13:51:14 +00:00
RiotRobot ec137cb5fb v39.4.0 2025-12-16 13:50:28 +00:00
David Baker ab4e24f115 Make token refresher init itself lazily (#5106)
* Make token refresher init itself lazily

It needs a network connection to do the init, so this would fail if
a client tried to do it at startup with no internet, causing the token
to just never be refreshed.

This just changes the API (compatibly) to do the init lazily.

The promise is kept is retain backwards compat, it can be removed
later.

* Make deviceId protected

* Fix tests
2025-12-12 18:23:43 +00:00
Will Hunt 2218ec4e31 Add _unstable_getRTCTransports to client. (#5104) 2025-12-11 17:35:54 +00:00
Michael Telatynski 319a8309c5 Update SonarCloud workflow action version 2025-12-11 16:35:10 +00:00
Timo 5af046f54f Use membershipID for session events (#5105)
* User membershipID for session events

* fix tests
2025-12-11 12:40:47 +00:00
Timo f97a9d9762 Remove three status cases that will never be set. (#5103) 2025-12-10 17:47:10 +00:00
renovate[bot] dc1a57a9f2 Update typescript-eslint monorepo to v8.48.1 (#5100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 17:52:39 +00:00
renovate[bot] 2d6111a04b Update actions/setup-node digest to 395ad32 (#5097)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 16:14:08 +00:00
renovate[bot] 710fd7859d Update dependency eslint-plugin-jsdoc to v61.5.0 (#5101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:15:42 +00:00
renovate[bot] e340a4ceaf Update dependency @matrix-org/matrix-sdk-crypto-wasm to v16 (#5102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:15:31 +00:00
renovate[bot] 3fa44e076e Update actions/stale digest to 9971854 (#5098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:13:49 +00:00
renovate[bot] 3f9fb9c936 Update matrix-org/sonarcloud-workflow-action digest to 820f7c2 (#5099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:13:35 +00:00
renovate[bot] 8db347a75e Update actions/checkout digest to 8e8c483 (#5096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:13:17 +00:00
renovate[bot] 8db3343280 Update dependency prettier to v3.7.0 (#5090)
* Update dependency prettier to v3.7.0

* Prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-09 15:12:27 +00:00
renovate[bot] 25c5a5b4ff Update dependency @casualbot/jest-sonar-reporter to v2.5.0 (#5003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:09:30 +00:00
RiotRobot a696e77652 v39.4.0-rc.0 2025-12-09 14:51:21 +00:00
Andy Balaam 582a76d87c Update encrypted state to say MSC4362 everywhere (#5079)
* Update encrypted state to say MSC4362 everywhere

* Fix test failure with encrypted state: handle empty string state key
2025-12-09 12:47:38 +00:00
Skye Elliot fdfddde55a Import room key bundles received after invite. (#5080)
* feat: Import room key bundles when received after invite.

* tests: Add spec test for room key bundle arriving after invite accepted.

* chore: Fix code quality issue (unnecessary async function).

* docs: Tidy up comments.

* refactor: Simplify key bundle importing after invite to one entrypoint.

- Remove `onReceiveToDeviceEvent` from `CryptoBackend`.
- Copy old room key bundle importing logic to
  `preprocessToDeviceEvents`.

* refactor: Move late bundle importing to main preprocess loop.

* fix: Use `Map` over `Record` to prevent prototype pollution.
2025-12-08 17:50:13 +00:00
Michael Telatynski 0ecfef2352 Update SonarCloud workflow action version 2025-12-04 13:22:19 +00:00
Michael Telatynski 4fdece6c1c Update SonarCloud action version in workflow 2025-12-04 13:14:18 +00:00
Michael Telatynski 6f0bce8708 Add label to skip Sonar coverage (#5094)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-04 12:22:38 +00:00
Michael Telatynski d3bdeb73f5 Avoid use of Optional type (#5093)
* Avoid use of Optional type

As we are likely to remove dependency on matrix-events-sdk

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tweak params

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-12-04 11:27:43 +00:00
RiotRobot 942fdf5bee Merge branch 'master' into develop 2025-12-02 14:45:10 +00:00
RiotRobot dd2635dbe6 v39.3.0 2025-12-02 14:44:30 +00:00
Will Hunt 3d1bcb73c1 Allow msc4354_sticky_key to be optional on sticky events. (#5073) 2025-11-27 11:54:49 +00:00
Michael Telatynski a960e686b3 Handle all response fields from /context API being optional (#5089)
* Handle all response fields from /context API being optional

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Simplify

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-27 11:12:39 +00:00
Michael Telatynski 946774c3fb Fix close-if-fork-develop job permissions (#5088)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-26 10:27:02 +00:00
renovate[bot] 15edbc8067 Update dependency @stylistic/eslint-plugin to v5.6.1 (#5083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:48:46 +00:00
renovate[bot] 1398ac24a2 Update typescript-eslint monorepo to v8.47.0 (#5086)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:48:38 +00:00
renovate[bot] c76df4cd8f Update all non-major dependencies (#5082)
* Update all non-major dependencies

* Make knip happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-25 15:43:23 +00:00
renovate[bot] a5e4dbf2d3 Update dependency matrix-widget-api to v1.15.0 (#5084)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:58:14 +00:00
renovate[bot] 3768187395 Update eslint-plugins (#5085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:58:04 +00:00
renovate[bot] 08d0ce25f1 Update actions/checkout action to v6 (#5087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 14:57:19 +00:00
RiotRobot 23241f18e2 v39.3.0-rc.0 2025-11-25 14:10:43 +00:00
Michael Telatynski 90da67aa95 Re-add truthy check on room name/avatar/alias events (#5081)
* Re-add truthy check on room name/avatar/alias events

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add regression test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-25 09:09:11 +00:00
renovate[bot] 0bf2702149 Update jest to v30 (major) (#4875)
* Update jest to v30

* Update snapshots & imports

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Make jest happier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-24 09:07:13 +00:00
RiotRobot c7a75c8824 Merge branch 'master' into develop 2025-11-18 14:27:42 +00:00
RiotRobot 98b2b9745d v39.2.0 2025-11-18 14:26:56 +00:00
Michael Telatynski 65d5b3172c Fix invalid state events corrupting room objects (#5078)
* Fix invalid room name/canonical alias corrupting room objects

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-11-18 10:46:24 +00:00
renovate[bot] 2f72f9e889 Update dependency eslint-plugin-jsdoc to v61.1.12 (#5074)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:30:01 +00:00
renovate[bot] 18f500a1f8 Update all non-major dependencies (#5076)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:01:33 +00:00
renovate[bot] b1df58796a Update typescript-eslint monorepo to v8.46.3 (#5075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 14:01:12 +00:00
RiotRobot 761b3771d6 v39.2.0-rc.0 2025-11-11 12:44:05 +00:00
Andrew Ferrazzutti df88edfda0 Delayed event management: split endpoints, no auth (#5066)
* Delayed event management: split endpoints, no auth

Add dedicated endpoints for each of the cancel/restart/send actions for
updating a delayed event, and make them unauthenticated.

Also keep support for the original endpoint where the update action is
in the request body, and make the split-endpoint versions fall back to
it if they are unsupported by the homeserver.

* Don't @link parameters in method docstrings

as TypeDoc doesn't support that

* Reduce code duplication

* Reduce code duplication again

* Add a little more test coverage

* Use split delayed event management for widgets

* Specify which eslint rule to ignore

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>

* Restore embedded non-split delay evt update method

Keep supporting it to not break widgets that currently use it.
Also add back the test for it.

* Deprecate the non-split delay evt update methods

* Comment to explain fallback to non-split endpoint

* Add backwards compatibility with authed endpoints

* Comment backwards compatibility helper method

* Await returned promises

because `return await promise` is at least as fast as `return promise`

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
2025-11-11 05:54:33 +00:00
David Langley 1dee1ba581 Fix media switching during legacy calls (#5069)
* Specify exact for deviceId

* Update mediaHandler.spec.ts

* fallback to ideal if exact fails

* Reduce cognitive complexity for sonar

* Add tests
2025-11-10 09:36:32 +00:00
pkuzco b274c74a30 do not set cache in authenticated fetch (#5020)
* do not set cache in authenticated fetch

* issue 5019 - updated comment

* do not set cache in authenticated fetch

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

* issue 5019 - updated comment

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

---------

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>
Co-authored-by: Bahaa Naamneh <glimm.no@gmail.com>
2025-11-06 14:36:27 +00:00
RiotRobot b489bb15cf Merge branch 'master' into develop 2025-11-04 14:02:00 +00:00
RiotRobot dff4922a42 v39.1.2 2025-11-04 14:01:14 +00:00
Michael Telatynski dc6ad0b54c Fix npm publish script (#5068)
The `id` is also an output from this workflow so needs re-adding, not sure how the token stuck around, I thought I removed that
2025-11-04 13:33:55 +00:00
RiotRobot 9769c05dc5 Merge branch 'master' into develop 2025-11-04 13:23:41 +00:00
RiotRobot dd379d3d4c v39.1.1 2025-11-04 13:22:58 +00:00
Michael Telatynski 1b884a3e52 Fix npm release missing template braces (#5067) 2025-11-04 13:00:29 +00:00
RiotRobot ddb164490e Merge branch 'master' into develop 2025-11-04 12:54:27 +00:00
RiotRobot 796135c7ce v39.1.0 2025-11-04 12:53:45 +00:00
Michael Telatynski 4cc4c01dd8 Remove call to npm dist-tag (#5065)
As it does not work for NPM Trusted Publishing

https://github.com/npm/cli/issues/8547

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-30 09:34:14 +00:00
RiotRobot 533b40922c v39.1.0-rc.1 2025-10-30 08:45:29 +00:00
Michael Telatynski 0ae483ce27 Use NPM Trusted Publishers over token (#5064)
* Use NPM Trusted Publishers over token

due to security changes being enacted next month by npm

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update npm as ubuntu-latest only has 10.x and 11.5.1 or later is necessary

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-29 16:07:11 +00:00
renovate[bot] dbc1fa87ed Update dependency eslint-plugin-jsdoc to v61.1.8 (#5058)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 23:03:02 +00:00
renovate[bot] 0a3675b971 Update typescript-eslint monorepo to v8.46.2 (#5059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 16:11:40 +00:00
renovate[bot] ab3f529d29 Update babel monorepo to v7.28.5 (#5057)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:57:44 +00:00
renovate[bot] 607b712a07 Update dependency sdp-transform to v3 (#5062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:32 +00:00
renovate[bot] b6d9e49277 Update dependency @stylistic/eslint-plugin to v5.5.0 (#5061)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:17 +00:00
renovate[bot] 731d5943e2 Update GitHub Artifact Actions (#5063)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:54:08 +00:00
renovate[bot] 01e7a43593 Update all non-major dependencies (#5060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 14:53:35 +00:00
RiotRobot b69a19ce7c v39.1.0-rc.0 2025-10-28 14:03:05 +00:00
Valere Fedronic 8703acb533 MatrixRTC: Disable room transport fallback for keys (#4929)
* matrixRTC: Disable room transport fallback for keys

* post rebase fix

* post merge fix

---------

Co-authored-by: Will Hunt <will@half-shot.uk>
2025-10-28 09:03:15 +00:00
Timo b59603d748 [MatrixRTC] Sticky Events support (MSC4354) (#5017)
* Implement Sticky Events MSC

* Renames

* lint

* some review work

* Update for support for 4-ples

* fix lint

* pull through method

* Fix the mistake

* More tests to appease SC

* Cleaner code

* Review cleanup

* Refactors based on review.

* lint

* Add sticky event support to the js-sdk

Signed-off-by: Timo K <toger5@hotmail.de>

* use sticky events for matrixRTC

Signed-off-by: Timo K <toger5@hotmail.de>

* make sticky events a non breaking change (default to state events. use joinConfig to use sticky events)

Signed-off-by: Timo K <toger5@hotmail.de>

* review
 - fix types (`msc4354_sticky:number` -> `msc4354_sticky?: { duration_ms: number };`)
  - add `MultiKeyMap`

Signed-off-by: Timo K <toger5@hotmail.de>

* Refactor all of this away to it's own accumulator and class.

* Add tests

* tidyup

* more test cleaning

* lint

* Updates and tests

* fix filter

* fix filter with lint

* Add timer tests

* Add tests for MatrixRTCSessionManager

* Listen for sticky events on MatrixRTCSessionManager

* fix logic on filtering out state events

* lint

* more lint

* tweaks

* Add logging in areas

* more debugging

* much more logging

* remove more logging

* Finish supporting new MSC

* a line

* reconnect the bits to RTC

* fixup more bits

* fixup testrs

* Ensure consistent order

* lint

* fix log line

* remove extra bit of code

* revert changes to room-sticky-events.ts

* fixup mocks again

* lint

* fix

* cleanup

* fix paths

* tweak test

* fixup

* Add more tests for coverage

* Small improvements

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* Document better

* fix sticky event type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix demo

Signed-off-by: Timo K <toger5@hotmail.de>

* fix tests

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* cleanup

* lint

* fix ci

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Half-Shot <will@half-shot.uk>
Co-authored-by: Robin <robin@robin.town>
2025-10-23 14:56:54 +00:00
Richard van der Hoff b0cbe22f64 Add CryptoApi.getSecretStorageStatus (#5054)
* Add `CryptoApi.getSecretStorageStatus`

`isSecretStorageReady` is a bit of a blunt instrument: it's hard to see from
logs *why* the secret storage isn't ready.

Add a new method which returns a bit more data.

* Update src/rust-crypto/rust-crypto.ts

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2025-10-23 11:04:28 +00:00
Timo 977d0322da Add parseCallNotificationContent (#5015)
* add parseCallNotificationContent

Signed-off-by: Timo K <toger5@hotmail.de>

* add tests

Signed-off-by: Timo K <toger5@hotmail.de>

* remove decline reason and better m.mentions check

Signed-off-by: Timo K <toger5@hotmail.de>

* cap ring duration to EX value (90s)

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-21 13:02:14 +00:00
RiotRobot dd7394c14c Merge branch 'master' into develop 2025-10-21 11:19:14 +00:00
RiotRobot f2d082064e v39.0.0 2025-10-21 11:18:29 +00:00
Andrew Ferrazzutti 2731e20893 MSC4140: support filters on delayed event lookup (#5038)
* MSC4140: support filters on delayed event lookup

Support looking up scheduled/finalised delayed events, and looking up a
single delayed event.

* Add test coverage for delayed event lookup filters

* Prettier

* Use it.each for test loop

* Support multiple delayIds

* Support single or multiple delayIds

As it may be more common to look up a single delayed event than to look
up many of them, support passing a single delayID in the lookup function
instead of needing to pass a single-element array.
2025-10-20 14:59:29 +00:00
renovate[bot] 502a513b5b Update eslint-plugins (#5050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 10:28:30 +00:00
renovate[bot] 3ac47e71cd Update eslint-plugins (#5049)
* Update eslint-plugins

* Bump matrix-org

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-15 09:49:42 +00:00
renovate[bot] 7c1e25e713 Update actions/setup-node action to v6 (#5047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 16:28:15 +00:00
renovate[bot] 2d90ad95f1 Update peter-evans/repository-dispatch action to v4 (#5048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:19:11 +00:00
renovate[bot] cd9794471f Update dependency @types/node to v18.19.130 (#5045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:17:41 +00:00
renovate[bot] b2d3ab8bc1 Update typescript-eslint monorepo to v8.46.0 (#5046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:17:11 +00:00
renovate[bot] d8b70ef83b Update actions/stale digest to 5f858e3 (#5043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:16:55 +00:00
renovate[bot] a67fb1fb8d Update all non-major dependencies (#5044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 15:13:48 +00:00
Will Hunt ddd6e77cde Handle redactions for sticky events (MSC4354) (#5037)
* Handle redactions.

* Add unit tests

* Refactor to support unstable insertion orders.

* lint

* tidy tidy

* Fix doc

* lint

* Assert userId

* Catch one occurance of sticky event errors not being caught
2025-10-14 13:49:31 +00:00
RiotRobot 2e9f5b6033 v39.0.0-rc.0 2025-10-14 13:34:57 +00:00
Timo fd949fe486 [MatrixRTC] Multi SFU support + m.rtc.member event type support (#5022)
* WIP

* temp

Signed-off-by: Timo K <toger5@hotmail.de>

* Fix imports

* Fix checkSessionsMembershipData thinking foci_preferred is required

* incorporate CallMembership changes
 - rename Focus -> Transport
 - add RtcMembershipData (next to `sessionMembershipData`)
 - make `new CallMembership` initializable with both
 - move oldest member calculation into CallMembership

Signed-off-by: Timo K <toger5@hotmail.de>

* use correct event type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix sonar cube conerns

Signed-off-by: Timo K <toger5@hotmail.de>

* callMembership tests

Signed-off-by: Timo K <toger5@hotmail.de>

* make test correct

Signed-off-by: Timo K <toger5@hotmail.de>

* make sonar cube happy (it does not know about the type constraints...)

Signed-off-by: Timo K <toger5@hotmail.de>

* remove created_ts from RtcMembership

Signed-off-by: Timo K <toger5@hotmail.de>

* fix imports

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/IMembershipManager.ts

Co-authored-by: Robin <robin@robin.town>

* rename LivekitFocus.ts -> LivekitTransport.ts

Signed-off-by: Timo K <toger5@hotmail.de>

* add details to `getTransport`

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* use DEFAULT_EXPIRE_DURATION in tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test `does not provide focus if the selection method is unknown`

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* Move `m.call.intent` into the `application` section for rtc member events.

Signed-off-by: Timo K <toger5@hotmail.de>

* review on rtc object validation code.

Signed-off-by: Timo K <toger5@hotmail.de>

* user id check

Signed-off-by: Timo K <toger5@hotmail.de>

* review: Refactor RTC membership handling and improve error handling

Signed-off-by: Timo K <toger5@hotmail.de>

* docstring updates

Signed-off-by: Timo K <toger5@hotmail.de>

* add back deprecated `getFocusInUse` & `getActiveFocus`

Signed-off-by: Timo K <toger5@hotmail.de>

* ci

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/matrixrtc/CallMembership.ts

Co-authored-by: Robin <robin@robin.town>

* lint

Signed-off-by: Timo K <toger5@hotmail.de>

* make test less strict for ew tests

Signed-off-by: Timo K <toger5@hotmail.de>

* Typescript downstream test adjustments

Signed-off-by: Timo K <toger5@hotmail.de>

* err

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
2025-10-08 19:12:29 +00:00
Michael Telatynski 7b3aed8a47 Tidy knip config (#5036)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-08 14:19:25 +00:00
Will Hunt b84a73c7cc Implement Sticky Events MSC4354 (#5028)
* Implement Sticky Events MSC

* Renames

* lint

* some review work

* Update for support for 4-ples

* fix lint

* pull through method

* Fix the mistake

* More tests to appease SC

* Cleaner code

* Review cleanup

* Refactors based on review.

* lint

* Store sticky event expiry TS at insertion time.

* proper type
2025-10-07 17:24:10 +00:00
Michael Telatynski a03cf054a8 Only use the first 3 viaServers specified (#5034)
* Only use the first 3 viaServers specified

To avoid HTTP 414 URI Too Long error

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-10-07 13:14:13 +00:00
RiotRobot b3d217717a Merge branch 'master' into develop 2025-10-07 12:06:14 +00:00
RiotRobot 3e6fe5f914 v38.4.0 2025-10-07 12:05:30 +00:00
Andy Balaam a213d177f9 Fetch the user's device info before processing a verification request (#5030)
* Use checked way to get OlmMachine

* Factor out two variables in onKeyVerificationEvent

* Make sure verification test waits for the request to be processed

* Fetch the user's device info before processing a verification request

If we don't have the device info for a user when we receive their
verification request, we ignore it. This change gives us the best
possible chance of having the right device data before we try to process
the verification.

Fixes #30693
Fixes #27819
2025-10-07 08:13:31 +00:00
Will Hunt e885ecf08d Stabilise extended profiles (now part of Matrix v1.16) (#5013)
* Stabilise extended profiles (v1.16)

* lint

* Add capacity

* Update profile keys

* copyright

* lint
2025-10-06 15:10:15 +00:00
pkuzco d1d9aba745 feat(client): allow disabling VoIP support (#5021)
* feat(client): allow disabling VoIP support

* feat(client): allow disabling VoIP support

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>

* add a unit-test for disableVoip option

* fix lint issue

---------

Signed-off-by: Bahaa Naamneh <b.naamneh@gmail.com>
Co-authored-by: Bahaa Naamneh <glimm.no@gmail.com>
2025-10-01 16:52:36 +00:00
Michael Telatynski 52bcc2c955 Update community PR check to exclude dependabot (#5029) 2025-10-01 12:01:33 +00:00
renovate[bot] 1994806c72 Update typescript-eslint monorepo to v8.44.1 (#5025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:31:31 +00:00
renovate[bot] c4d1fd2c67 Update guibranco/github-status-action-v2 digest to 5530c59 (#5023)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:18:48 +00:00
renovate[bot] 7ad8288525 Update dependency @types/node to v18.19.127 (#5024)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:08:41 +00:00
renovate[bot] 31a42964e6 Update all non-major dependencies (#5026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:07:57 +00:00
renovate[bot] 2b1d37813c Update dependency @stylistic/eslint-plugin to v5.4.0 (#5027)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 14:07:29 +00:00
RiotRobot e6fd0c58ff v38.4.0-rc.0 2025-09-30 12:41:53 +00:00
Will Hunt 41d70d0b5d Add call intent to RTC call notifications (#5010)
* Add media hint specifier

* Refactor to use m.call.intent and to apply to membership

* lint

* Add a mechanism to get the consensus of a call.

* Update tests

* Expose option to update the call intent.

* Better docs

* Add tests

* lint
2025-09-25 09:02:35 +00:00
Skye Elliot a08a2737e1 Implement experimental encrypted state events. (#4994)
* feat: Implement experimental encrypted state events.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* fix: Add cast from StateEvents[K] to IContent.

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2025-09-24 11:44:17 +00:00
Andy Balaam dbe441de33 Exclude cancelled requests from in-progress lists (#5016)
Fixes https://github.com/element-hq/element-web/issues/29882

When we ask for the in-progress verification requests, exclude requests
that have been cancelled. This means that we don't erroneously tell the
user that the new request they are about to create has been cancelled.
2025-09-23 13:51:19 +00:00
RiotRobot 9f3ca71495 Merge branch 'master' into develop 2025-09-23 12:13:04 +00:00
RiotRobot ef97df8ed0 v38.3.0 2025-09-23 12:12:22 +00:00
renovate[bot] 7f74fcc9f7 Update dependency debug to v4.4.3 (#5012)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 11:30:33 +00:00
renovate[bot] e39644ad08 Update dependency @stylistic/eslint-plugin to v5.3.1 (#5002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 09:49:25 +00:00
renovate[bot] df0f0074b4 Update dependency uuid to v13 (#5009)
* Update dependency uuid to v13

* Make jest happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 20:00:04 +00:00
renovate[bot] 29fbed5603 Update dependency p-retry to v7 (#5008)
* Update dependency p-retry to v7

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Make jest happier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 18:45:28 +00:00
renovate[bot] 5ee6fc196b Update actions/stale action to v10 (#5007)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 16:23:39 +00:00
renovate[bot] 961e32a3bb Update actions/setup-node action to v5 (#5006)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 15:30:02 +00:00
renovate[bot] 25f0418fce Update babel monorepo to v7.28.4 (#4998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:10:18 +00:00
RiotRobot 0ce751c462 v38.3.0-rc.0 2025-09-16 14:09:05 +00:00
renovate[bot] b7b3588cb8 Update shogo82148/actions-upload-release-asset digest to 59cbc56 (#4996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:58:13 +00:00
renovate[bot] 72846c713d Update actions/github-script action to v8 (#5005)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:56:33 +00:00
renovate[bot] 7c5229b4c8 Update typescript-eslint monorepo to v8.43.0 (#5004)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:50:53 +00:00
renovate[bot] dd08388397 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v15.3.0 (#5001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:50:32 +00:00
renovate[bot] ec1ccebcca Update dependency typedoc to v0.28.13 (#5000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:46:32 +00:00
renovate[bot] e9b45cc504 Update dependency @types/node to v18.19.124 (#4999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:46:01 +00:00
renovate[bot] 5d4df65c09 Update all non-major dependencies (#4997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 13:45:36 +00:00
RiotRobot 2706873948 Merge branch 'master' into develop 2025-09-16 11:40:18 +00:00
RiotRobot 4a9006aea6 v38.2.0 2025-09-16 11:39:28 +00:00
Michael Telatynski 43c72d5bf5 Merge commit from fork
* Validate room upgrade relationships in MatrixClient::getJoinedRooms

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-09-16 12:36:14 +01:00
Andy Balaam e551b92a07 Update matrix-sdk-crypto-wasm to 15.2.0 (#4991)
* Update matrix-sdk-crypto-wasm to 15.2.0

Most relevant changes:

-   History sharing: improve efficiency of building key bundle
    ([matrix-rust-sdk#5513](https://github.com/matrix-org/matrix-rust-sdk/issues/5513))

* Work around matrix-rust-sdk#5643

Modify the message content coming from Rust API to include the missing
property `msgtype: m.key.verification.request`
2025-09-11 14:53:13 +00:00
Richard van der Hoff 32f51e852b History sharing: do /keys/query before checking for key bundle (#4992)
* History sharing: do `/keys/query` before checking for key bundle

The next release of matrix-sdk-crypto-wasm will check that the device that sent
us the key bundle data was correctly cross-signed by its owner, which means we
need to have the owner's cross-signing keys before we check if we have the
bundle.

This replicates a change made in the Rust SDK, at https://github.com/matrix-org/matrix-rust-sdk/pull/5510/files#diff-9f89fa75c4eb3743ae674be1bb90f75169bd815a259917799c71b8a546449d51R133-R140.

* fix unit test

* Comment
2025-09-11 10:38:14 +00:00
RiotRobot 82aa04d894 Merge branch 'master' into develop 2025-09-09 16:30:39 +00:00
RiotRobot ff89c9ec42 v38.1.0 2025-09-09 16:29:55 +00:00
Svajūnas Budrys ccd825fb39 Remove knock state on join (#4977)
Signed-off-by: Svajunas Budrys <svajunas.budrys.sb@gmail.com>
2025-09-04 09:31:26 +00:00
renovate[bot] b313eb5912 Update dependency @types/sdp-transform to v2.15.0 (#4987)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 09:06:51 +00:00
renovate[bot] 2b12675675 Update typescript-eslint monorepo to v8.41.0 (#4989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 17:14:06 +00:00
renovate[bot] 246788b874 Update shogo82148/actions-upload-release-asset digest to e6cd457 (#4985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 17:13:57 +00:00
renovate[bot] b0b80401aa Update actions/upload-pages-artifact action to v4 (#4990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:39:36 +00:00
renovate[bot] 6a0164f37f Update dependency knip to v5.63.0 (#4988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:30:21 +00:00
renovate[bot] f963d61bcb Update typedoc (#4986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 13:29:33 +00:00
RiotRobot b32619ad24 v38.1.0-rc.0 2025-09-02 12:53:40 +00:00
Tulir Asokan 3d3c3ba55f Fix m.topic format (#4984)
* Fix m.topic format

Fixes #4902

Signed-off-by: Tulir Asokan <tulir@maunium.net>

* Update tests

Signed-off-by: Tulir Asokan <tulir@maunium.net>

* Fix formatting

* Re-add temporary support for invalid form

Signed-off-by: Tulir Asokan <tulir@maunium.net>

---------

Signed-off-by: Tulir Asokan <tulir@maunium.net>
2025-09-02 12:14:08 +00:00
Timo d62c658a72 Remove custom org.matrix.msc4075.rtc.notification.parent relation type (#4979)
* Remove custom `org.matrix.msc4075.rtc.notification.parent` relation type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* Fix test post rebase

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-09-02 08:36:26 +00:00
David Baker bdc4a69023 Fix stable-suffixed MSC4133 support (#4983)
* Fix stable-suffixed MSC4133 support

It looked for the ".stable" suffixed feature to work out what URL to use but not to see whether the server supported it.

This will only be relevant until the next spec release but may as well fix it.

See also https://github.com/element-hq/element-web/pull/30649

* Fix awaiting
2025-09-01 15:34:01 +01:00
Bas Nijholt ab892420b5 Fix thread edit aggregation race condition (#4980)
* test(thread): add regression tests for edit-race; ensure reaction aggregation idempotence

- Edit race: add failing test when `Replace` aggregated pre-init
- Reaction: ensure pre-init aggregation and dedup on replay
- Strengthen assertions for ordering and content

* fix(thread): apply edits after init; keep reactions pre-init; remove redundant aggregation
- Defer Replace aggregation until thread initialised and event is in timeline
- Aggregate Annotation pre-init to preserve reaction summaries
- Rely on EventTimelineSet to aggregate post-insert
- Fixes: element-hq/element-web#30617

* style: run prettier; docs: clarify reaction pre-init comment about counts
2025-08-28 09:03:33 +00:00
RiotRobot ed607c48b0 Merge branch 'master' into develop 2025-08-27 13:32:25 +00:00
RiotRobot a8d75b81e5 v38.0.0 2025-08-27 13:31:35 +00:00
Timo 2f1d654f14 MatrixRTC: Add RTC decline event (#4978)
* Add decline event

Signed-off-by: Timo K <toger5@hotmail.de>

* add `client.sendRtcDecline`.

Signed-off-by: Timo K <toger5@hotmail.de>

* remove `decline.reason` field.

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 15:21:40 +00:00
Timo c4c7f94514 Make a MatrixRTCSession emit once the RTCNotification is sent (#4976)
* MatrixRTCSession emits once the rtc notification is sent.

Signed-off-by: Timo K <toger5@hotmail.de>

* update correct type description

Signed-off-by: Timo K <toger5@hotmail.de>

* Add test

Signed-off-by: Timo K <toger5@hotmail.de>

* fix imports

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 15:16:02 +00:00
David Baker 1fac06e223 Use hydra semantics for unknown room versions (#4957)
This inverts the check for whether to use hydra semantics to only NOT use
it for known, old room versions and use hydra for everything else, so
rooms with versions we don't know about will use hydra semantics.

This will cause any rooms using old/experiental versions unknown to
the js-sdk to break, but will mean that wehn the next room version
comes out, we'll use hydra for it which is, of course, not a given,
but is way more likely than going back to the old semantics.

The mobile Element clients currently hardcode hydra versions (ie.
as it is without this change, but we expect them to make this same
change soon after the hydra release.

We do NOT expect this to land with the hydra release, but target it for
the release after.

Reverts 1e5054a8ff87f83b0875916aa16f435853bf165a from https://github.com/matrix-org/matrix-js-sdk/pull/4937

See https://github.com/element-hq/element-meta/issues/2921 for public
discussion.
2025-08-26 10:49:44 +00:00
RiotRobot 77c118084b v38.0.0-rc.1 2025-08-21 14:16:23 +00:00
ElementRobot 097bfe451a Release tranche of breaking changes (#4963) (#4975)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-08-21 15:08:48 +01:00
Michael Telatynski b80d0091d2 Release tranche of breaking changes (#4963)
* Remove deprecated `IJoinRoomOpts.syncRoom` option (#4914)

This option is non-functional, and was deprecated in
https://github.com/matrix-org/matrix-js-sdk/pull/4913. Remove it altogether.

* Remove support for `onlyData != true` (#4939)

* Remove deprecated fields, methods, utilities (#4959)

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2025-08-21 13:24:36 +00:00
Timo 3a33c658bb Expose the StatusChanged event through the RTCSession (#4974)
* Expose the StatusChanged event through the RTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* add membershipManagerStatus public get field

Signed-off-by: Timo K <toger5@hotmail.de>

* add probably left as a getter

Signed-off-by: Timo K <toger5@hotmail.de>

* add tests for coverage

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 16:48:56 +00:00
Timo 81e42b9531 Add probablyLeft event to the MatrixRTCSession (#4962)
* Add probablyLeft emission to the MatrixRTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* add docstring

Signed-off-by: Timo K <toger5@hotmail.de>

* Review: add additional test + fix pending promises issue.

Signed-off-by: Timo K <toger5@hotmail.de>

* review: `Pick` only a subset of membership manager events

Signed-off-by: Timo K <toger5@hotmail.de>

* reveiw: update probablyLeft logic to be more straight forward

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* make test not wait for 5s

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* fix linter (rebase)

Signed-off-by: Timo K <toger5@hotmail.de>

* fix import

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 12:05:07 +00:00
renovate[bot] 7f7ecd060d Update dependency lint-staged to v16.1.5 (#4969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 15:06:35 +00:00
renovate[bot] 6126ee125a Update typedoc (#4970)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-08-19 14:27:08 +00:00
renovate[bot] 78f718ff82 Update typescript-eslint monorepo to v8.39.1 (#4971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:27:09 +00:00
renovate[bot] c1c1be0c5d Update actions/download-artifact action to v5 (#4973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:26:16 +00:00
renovate[bot] 1952eaa1ff Update actions/checkout action to v5 (#4972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:26:08 +00:00
renovate[bot] 8851f8b07c Update dependency @types/node to v18.19.123 (#4968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:51 +00:00
renovate[bot] 15eafe34b3 Update babel monorepo to v7.28.3 (#4966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:25 +00:00
renovate[bot] f0d48236fa Update dependency @stylistic/eslint-plugin to v5.2.3 (#4967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:23 +00:00
renovate[bot] dde9d48726 Update shogo82148/actions-upload-release-asset digest to 62365f2 (#4965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 13:24:06 +00:00
RiotRobot 6d046edcb2 v38.0.0-rc.0 2025-08-19 13:08:13 +00:00
Timo 2abf7ca795 Allow multiple rtc sessions per room (with different sessionDescriptions) (#4945)
* Introduce sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* Make sessionDescription part of a MatrixRTCSession

Signed-off-by: Timo K <toger5@hotmail.de>

* Make session manager only menage session for one sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* make membership manager aware about session (application + id)
Before this was just hardcoded to a call session

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix doc comments

Signed-off-by: Timo K <toger5@hotmail.de>

* Make fields private,  improve comments, improve whitespace, don't use deprecated fields

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for other application end event

Signed-off-by: Timo K <toger5@hotmail.de>

* rename call -> session

Signed-off-by: Timo K <toger5@hotmail.de>

* fix tests

Signed-off-by: Timo K <toger5@hotmail.de>

* remove id check since its already part of `deepCompare(membership.sessionDescription, sessionDescription)`

Signed-off-by: Timo K <toger5@hotmail.de>

* remove scope related tests. The id should be the only thing that scopes sessions. everything else is application (session type) specific

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for custom sessionDescription

Signed-off-by: Timo K <toger5@hotmail.de>

* callMembershipsForRoom to default to call

Signed-off-by: Timo K <toger5@hotmail.de>

* roomSessionForRoom backwards compatible (And deprecate the call specific method)

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-19 11:58:57 +00:00
Olivier D 2b46579bd8 Add support for login_hint in authorization url generation (#4943)
* add login_hint to authorization url generation

Signed-off-by: olivier <odelcroi@gmail.com>

Signed-off-by: olivier <odelcroi@gmail.com>

fix lint

Signed-off-by: olivier <odelcroi@gmail.com>

* update doc

* fix linter

---------

Signed-off-by: olivier <odelcroi@gmail.com>
Co-authored-by: mcalinghee <mcalinghee.dev@gmail.com>
2025-08-18 15:06:59 +00:00
fkwp 6d42ed338e Only process MatrixRTC sessions associated with calls for callMembershipsForRoom (#4960)
* Only process MatrixRTC sessions associated with calls

* tests: Only process MatrixRTC sessions associated with calls

* linting
2025-08-14 12:44:41 +00:00
RiotRobot ef080c25f9 Merge branch 'master' into develop 2025-08-11 11:13:57 +00:00
RiotRobot c8d7b458b2 v37.13.0 2025-08-11 11:13:14 +00:00
ElementRobot f1ba8a8775 Support v12 rooms in maySendEvent (#4955) (#4956)
Follows on from https://github.com/matrix-org/matrix-js-sdk/pull/4937

(cherry picked from commit dea184e9ec)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-08-08 15:17:12 +01:00
ElementRobot d21adf568a Support for creator power level (#4937) (#4954)
* Support for creator power level

Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289).

* Update unit test

* Hardcode versions

as room versions strings aren't ordered

* Add test for v12 rooms

* Use more compact syntax



* Fix doc



* Fix additionalCreators from PR edit

* Split out hydra room version check

* Move power level logic into room state

Which already has knowledge of the room create event

* Add docs

* Fix unused bits

* Fix docs

* Fix lying docstring

* Reverse logic for hydra semantics

Assume unknown room versions do use hydra

* Use backticks



* Switch back to hardcoding just the two hydra versions

---------



(cherry picked from commit e119bf9040)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-08-08 15:09:10 +01:00
David Baker dea184e9ec Support v12 rooms in maySendEvent (#4955)
Follows on from https://github.com/matrix-org/matrix-js-sdk/pull/4937
2025-08-08 13:19:51 +00:00
David Baker e119bf9040 Support for creator power level (#4937)
* Support for creator power level

Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289).

* Update unit test

* Hardcode versions

as room versions strings aren't ordered

* Add test for v12 rooms

* Use more compact syntax

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* Fix doc

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* Fix additionalCreators from PR edit

* Split out hydra room version check

* Move power level logic into room state

Which already has knowledge of the room create event

* Add docs

* Fix unused bits

* Fix docs

* Fix lying docstring

* Reverse logic for hydra semantics

Assume unknown room versions do use hydra

* Use backticks

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Switch back to hardcoding just the two hydra versions

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2025-08-08 10:23:49 +00:00
renovate[bot] c7f982e190 Update typedoc (#4949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 14:25:57 +00:00
renovate[bot] 2e2dd628c1 Update typescript-eslint monorepo to v8.39.0 (#4952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:56:03 +00:00
renovate[bot] 5ac5a8a799 Update dependency @babel/runtime to v7.28.2 (#4950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:55:49 +00:00
renovate[bot] 7d75ab417a Update dependency lint-staged to v16.1.4 (#4948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:40:14 +00:00
renovate[bot] 3ca81e409a Update dependency @types/node to v18.19.121 (#4947)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 13:40:03 +00:00
RiotRobot 764fdb1d30 v37.13.0-rc.0 2025-08-05 12:51:02 +00:00
Timo c2d25d9377 Fix possible unknown state (With reproducible test) (#4944)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-01 09:13:35 +00:00
Richard van der Hoff c4e1e0723e Experimental support for sharing encrypted history on invite (#4920)
* tests: Cross-signing keys support in `E2EKeyReceiver`

Have `E2EKeyReceiver` collect uploaded cross-signing keys, so that they can be
returned by `E2EKeyResponder`.

* tests: Signature upload support in `E2EKeyReceiver`

Have `E2EKeyReceiver` collect uploaded device signatures, so that they can be
returned by `E2EKeyResponder`.

* tests: Implement `E2EOTKClaimResponder` class

A new test helper, which intercepts `/keys/claim`, allowing clients under test
to claim OTKs uploaded by other devices.

* Expose experimental settings for encrypted history sharing

Add options to `MatrixClient.invite` and `MatrixClient.joinRoom` to share and
accept encrypted history on invite, per MSC4268.

* Clarify pre-join-membership logic

* Improve tests

* Update spec/integ/crypto/cross-signing.spec.ts

Co-authored-by: Hubert Chathi <hubertc@matrix.org>

---------

Co-authored-by: Hubert Chathi <hubertc@matrix.org>
2025-07-29 15:42:35 +00:00
RiotRobot 56b24c0bdc Merge branch 'master' into develop 2025-07-29 12:59:32 +00:00
RiotRobot aa7a709e3f v37.12.0 2025-07-29 12:58:45 +00:00
Valere Fedronic c57c47319e MatrixRTC: comply with the manageMediaKeys EncryptionConfig option (#4942)
* MatrixRTC: comply with the `manageMediaKeys` JoinConfig option

* add additional test for reception

* add comments about temporary solution
2025-07-28 15:11:29 +00:00
Richard van der Hoff 812d0aaef6 Use js-sdk logger in rust sdk (#4918) 2025-07-25 09:57:25 +00:00
Richard van der Hoff 61e07633df Update to matrix-sdk-crypto-wasm 15.1.0, and add new ShieldStateCode.MismatchedSender (#4916)
* test: add a flushPromises

this seems to be needed because `initRustCrypto` now ends up doing slightly
less awaiting

* Support new `ShieldStateCode.MismatchedSender`

* Update to matrix-sdk-crypto-wasm 15.1.0

* Add `waitFor` and use it instead of `flushPromises`

* minor lints and fixes

* another lint fix
2025-07-24 13:35:55 +00:00
Richard van der Hoff c7dbd6e33b fetch api: add support for downloading raw response (#4917)
* Factor out `BaseRequestOpts`

... to make it easier to find the docs from methods that use it.

* fetch api: add support for downloading raw response

I need to make an authenticated request to the media repo, and expect to get a
binary file back. AFAICT there is no easy way to do that right now.

* Clarify doc strings

* Various fixes
2025-07-24 11:06:52 +00:00
Richard van der Hoff 556494b8f0 Attempt deflaking of queueToDevice test (#4936) 2025-07-24 09:20:54 +00:00
renovate[bot] bf3b4e81b2 Update typescript-eslint monorepo to v8.37.0 (#4935)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 16:36:31 +00:00
renovate[bot] 2710600389 Update all non-major dependencies (#4930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 16:12:13 +00:00
renovate[bot] ca168c494b Update dependency @types/node to v18.19.120 (#4931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 16:12:11 +00:00
renovate[bot] 759f5ed3eb Update dependency typedoc-plugin-mdn-links to v5.0.4 (#4932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 13:38:21 +00:00
renovate[bot] 53deedd2d6 Update dependency @stylistic/eslint-plugin to v5.2.0 (#4934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 13:37:49 +00:00
RiotRobot 4fbb9f92a7 v37.12.0-rc.0 2025-07-22 13:23:33 +00:00
Richard van der Hoff 1a58ce4649 Simplify declaration of ResponseType (#4926)
Currently, this is looking for a `json` property on `IHttpOpts`. There is no
such property, so that part of the declaration is completely redundant, and we
may as well remove it.

I looked into making it check `IRequestOpts`, which *does* have a `json`
property, but couldn't make it work.

Also add some docs, while we're there.
2025-07-22 10:07:56 +00:00
dependabot[bot] 32509d1fd1 Bump form-data from 4.0.0 to 4.0.4 (#4928)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 09:03:52 +00:00
Timo 11c9e39e5a Custom abort timeout logic for restarting delayed events that is compatible with the widget api (#4927)
* add custom local timout + add delay to 0 for normal local timeout.

* consider retry limits for new custom error

* mock the AbortError so we can reuse `actionUpdateFromErrors`

* update comment
2025-07-21 18:42:21 +00:00
toriningen 0ce944f3da Fix deep import incompatibility (#4924) (#4925) 2025-07-21 08:08:24 +00:00
Richard van der Hoff 38e04c8fb0 Give MatrixClient.invite an options param (#4919) 2025-07-19 07:29:12 +00:00
Valere Fedronic 1fcbc6ebeb RTCEncryptionManager: Joiner key rotation grace period (#4911)
* RTCEncryptionManager: Joiner key rotation grace period

* Test to clarify useKeyDelay and keyRotationGracePeriodMs interference

* make test more configurable

* rename delayRolloutTimeMillis to useKeyDelay same as config option

* rename skipRotationGracePeriod to keyRotationGracePeriodMs

* clarify that oldMemberships is not used by RTCEncryptionManager

* improve doc

* cleanup test

* more comment in test

* comment additions

* cleanup runOnlyPendingTimers

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-07-18 15:16:45 +00:00
Robin aa79236ce2 Allow sending notification events when starting a call (#4826)
* Make it easier to mock call memberships for specific user IDs

* Allow sending notification events when starting a call

* rename notify -> notification

* replace `joining` concept with `ownMembership`

* introduce new `m.rtc.notification` event alongside `m.call.notify`

* send new notification event alongside the deprecated one

* Test for new notification event type

* update relation string to match msc

* review

* fix doc errors

* fix tests + format

* remove anything decline related

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-07-18 12:42:57 +00:00
RiotRobot f8f1bf3837 Merge branch 'master' into develop 2025-07-15 14:45:35 +00:00
RiotRobot 946cf4f359 v37.11.0 2025-07-15 14:44:53 +00:00
Valere Fedronic c077201f2a EncryptionManager: un-deprecating EncryptionManager.getEncryptionKeys (#4912)
* EncryptionManager: should be able to re-emit keys

* fix typo in test file name

* review unneeded cast

* remove bad comment
2025-07-15 07:47:03 +00:00
Richard van der Hoff 53f2ad41d6 Deprecate non-functional IJoinRoomOpts.syncRoom (#4913)
I don't know when this last did something, but it's been a while.
2025-07-10 08:40:17 +00:00
Richard van der Hoff be15a709c6 Tests: gate logging behind DEBUG env var (#4903)
* Add `DebugLogger` type for logging matrix-js-sdk to `debug`

* unit tests for DebugLogger

* Use `DebugLogger` in some tests

* Use `DebugLogger` in rust-crypto.spec

* test-utils: silence some logging
2025-07-10 06:15:00 +00:00
renovate[bot] 090b8079db Update all non-major dependencies (#4905)
* Update all non-major dependencies

* Prettier

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-09 09:39:57 +00:00
Richard van der Hoff 06a1e1a88a More incorrect logger use (#4904)
A couple of places where we were still using the legacy logger
2025-07-08 15:37:17 +00:00
renovate[bot] 024b62bba0 Update dependency @stylistic/eslint-plugin to v5 (#4910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:36:47 +00:00
renovate[bot] 6bfb911cf7 Update dependency @types/node to v18.19.115 (#4906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:58:11 +00:00
renovate[bot] ad5da7cfa1 Update babel monorepo to v7.28.0 (#4909)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:38:22 +00:00
renovate[bot] e55bd1e14c Update typescript-eslint monorepo to v8.35.1 (#4908)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:38:09 +00:00
renovate[bot] 119e859741 Update dependency typedoc to v0.28.7 (#4907)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 13:37:59 +00:00
RiotRobot b7cce93edc v37.11.0-rc.0 2025-07-08 13:21:56 +00:00
Valere Fedronic e5c8c20a34 MatrixRTC: Refactor | Introduce a new Encryption manager (used with experimental to device transport) (#4799)
* refactor: New encryption manager BasicEncryptionManager for todevice

fixup: bad do not commit

* fix: ToDevice transport not setting the sent_ts

* test: BasicEncryptionManager add statistics tests

* code review

* feat: Encryption manager just reshare on new joiner

* refactor: Rename BasicEncryptionManger to RTCEncryptionManager

* fixup: RTC experimental todevice should use new encryption mgr

* fixup: use proper logger hierarchy

* fixup: RTC rollout first key asap even if no members to send to

* fixup: RTC add test for first key use

* fixup! emitting outbound key before anyone registered

* fix: quick patch for transport switch, need test

* test: RTC encryption manager, add test for transport switch

* post rebase fix

* Remove bad corepack commit

* review: cleaning, renaming

* review: cleaning and renaming

* stop using root logger in favor of a parent logger

* post merge fix broken test

* remove corepack again

* fix reverted changes after a merge

* review: Properly deprecate getEncryptionKeys

* review: rename ensureMediaKeyDistribution to ensureKeyDistribution

* review: use OutdatedKeyFilter instead of KeyBuffer
2025-07-08 12:43:16 +00:00
Neil Johnson 137379b7b7 Update README.md
style
2025-07-04 17:43:29 +01:00
Neil Johnson 4981efa54b Update README to make Element sponsorship explicit (#4901) 2025-07-04 17:41:35 +01:00
Richard van der Hoff 6e9740d787 Use client logger in more places (crypto code) (#4900)
* Use client logger for `RustBackupManager`

* use client logger in `CrossSigningIdentity`

* use client logger in `OutgoingRequestProcessor`

* RoomEncryptor: use correct logger for logDuration

use the logger for this specific event, rather than the more general one for the room

* Use client logger in `RoomEncryptor`
2025-07-04 07:35:42 +00:00
Richard van der Hoff 70257e0ab4 use client logger in MatrixRTCSessionManager (#4898) 2025-07-03 15:56:51 +00:00
Valere Fedronic 9baba151c6 Move ClientEvent docs to the event itself instead of the type map (#4894)
* doc: Add ClientEvent doc on the event enum

* Cleanup: Remove useless doc on EventHandlerMap

* quick format
2025-07-03 12:26:10 +00:00
Richard van der Hoff b4672e26ec Use client logger in more places (core code) (#4899)
* Use client logger for sync

Use the logger attached to the MatrixClient when writing log messages out of
the sync api. This helps figure out what's going on when multiple clients are
running in the same JS environment.

* Use client logger for to-device message queue

* Use client logger in `PushProcessor.rewriteDefaultRules`

* use client logger in `ServerCapabilities`

* Mark global `logger` as deprecated
2025-07-03 09:58:01 +01:00
Richard van der Hoff 940d358b0e Test: stop loading Olm into global namespace (#4895)
* Test: stop loading Olm into global namespace

Now that the js-sdk no longer relies on libolm, there is no need to populate
`globalThis.Olm`. Remove the code that did so (or relied on it being done).

* fix lint
2025-07-02 14:48:45 +00:00
Valere Fedronic 161c12f5d5 crypto: Add new ClientEvent.ReceivedToDeviceMessage with proper OlmEncryptionInfo support (#4891)
* crypto: Add new ClientEvent.ReceivedToDeviceMessage

refactor rename ProcessedToDeviceEvent to ReceivedToDeviceEvent

* fix: Restore legacy isEncrypted() for to-device messages

* Update test for new preprocessToDeviceMessages API

* quick fix on doc

* quick update docs and renaming

* review: Better doc and names for OlmEncryptionInfo

* review: Remove IToDeviceMessage alias and only keep IToDeviceEvent

* review: improve comments of processToDeviceMessages

* review: pass up encrypted event when no crypto callbacks

* review: use single payload for ReceivedToDeviceMessage

* fix linter

* review: minor comment update
2025-07-02 08:02:23 +00:00
RiotRobot de659d6431 Merge branch 'master' into develop 2025-07-01 14:54:29 +00:00
RiotRobot b095aa600d v37.10.0 2025-07-01 14:53:40 +00:00
David Baker 18ea8befdc Specify typeroot (#4892)
This prevents tsc from picking up random types from parent directories
such as in situations like an element-web layered build, and generally
seems like good hygiene as we don't want to pick up random types from
whatever directory we happen to be checked out into.
2025-07-01 11:31:28 +00:00
Timo 4f9ca2c697 Remove LegacyMembershipManager (#4862)
* Remove `LegacyMemberhsipManager`

* remove tests from rtc session
Those tests were only run with the legacy membership manager and are redundant with the memberhsip manager test spec.

* fix tests

* dont use non existing TestManager anymore

* remove fails for legacy

* fix another test
2025-06-26 12:19:51 +00:00
renovate[bot] 57a4dc8841 Update dependency @types/node to v18.19.112 (#4886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 12:34:38 +00:00
renovate[bot] 841c02e56d Update shogo82148/actions-upload-release-asset digest to 610b198 (#4885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 11:04:54 +00:00
renovate[bot] 6ece4c3c16 Update all non-major dependencies (#4888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 16:44:15 +00:00
renovate[bot] 5a3c07f91d Update typescript-eslint monorepo to v8.34.1 (#4887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:54:48 +00:00
renovate[bot] 3ed4b3ed50 Update mheap/github-action-required-labels digest to 8afbe8a (#4884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:20:47 +00:00
renovate[bot] 9ca9bd9baf Update guibranco/github-status-action-v2 digest to 741ea90 (#4883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:02:53 +00:00
RiotRobot 0265f6ea2d v37.10.0-rc.0 2025-06-24 12:40:23 +00:00
Richard van der Hoff 8f597f0f87 Update matrix-sdk-crypto-wasm to 15.0.0 (#4882)
For js-sdk users, this includes the following:

    -   Send stable identifier `sender_device_keys` for MSC4147 (Including device keys with Olm-encrypted events).
        ([#4964](https://github.com/matrix-org/matrix-rust-sdk/pull/4964))

    -   Check the `sender_device_keys` field on _all_ incoming Olm-encrypted to-device messages and ignore any to-device messages which include the field but whose data is invalid (as per [MSC4147](https://github.com/matrix-org/matrix-spec-proposals/pull/4147)).
        ([#4922](https://github.com/matrix-org/matrix-rust-sdk/pull/4922))

    -   Fix bug which caused room keys to be unnecessarily rotated on every send in the presence of blacklisted/withheld devices in the room.
        ([#4954](https://github.com/matrix-org/matrix-rust-sdk/pull/4954))

    -   Fix [matrix-rust-sdk#2729](https://github.com/matrix-org/matrix-rust-sdk/issues/2729) which in rare cases can cause room key oversharing.
        ([#4975](https://github.com/matrix-org/matrix-rust-sdk/pull/4975))
2025-06-23 17:35:37 +00:00
Michael Telatynski 67df2a53c4 Remove redundant git-revision.txt file (#4881)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-20 08:08:30 +00:00
Patrick Cloke 8367277894 Allow customizing the IndexedDB database prefix used by Rust crypto. (#4878)
* Allow customizing the IndexedDB database prefix used by Rust crypto.

Related to #3974

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>

* Rename argument

---------

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>
2025-06-18 09:07:35 +00:00
RiotRobot 4efb27354f Merge branch 'master' into develop 2025-06-17 13:09:40 +00:00
RiotRobot 28bc90563e v37.9.0 2025-06-17 13:09:06 +00:00
Will Hunt f9be1bf57a Add prepare script (#4877)
* Add prepare script

* Remove 'prepack' script.
2025-06-17 10:18:19 +00:00
Richard van der Hoff adaf921623 Remove @matrix-org/olm from dependency list (#4876)
Olm is needed at build time to run the tests, but is no longer needed at
runtime.
2025-06-12 09:28:39 +00:00
Travis Ralston cdece6cb9f Redact on ban: Client implementation (#4867)
* First pass implementation

* fix naming/docs

* apply lint

* Add test for existing behaviour

* Add happy path tests

* Fix bug identified by tests

* ... and this is why we add negative tests too

* Add some sanity tests

* Apply linter
2025-06-10 14:28:02 +00:00
renovate[bot] d438e25f87 Update dependency @stylistic/eslint-plugin to v4.4.1 (#4873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 14:16:20 +00:00
renovate[bot] 73d8f4384d Update all non-major dependencies (#4870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:32 +00:00
renovate[bot] b0cb6aa724 Update babel monorepo (#4871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:20 +00:00
renovate[bot] dfc26f8aa1 Update definitelyTyped (#4872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:11 +00:00
renovate[bot] 286fb8f752 Update typescript-eslint monorepo to v8.33.1 (#4874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:55:42 +00:00
RiotRobot 921de6807d v37.9.0-rc.0 2025-06-10 13:16:01 +00:00
RiotRobot d4e7b949e7 Merge branch 'master' into develop 2025-06-10 12:00:47 +00:00
RiotRobot 40bc833bb7 v37.8.0 2025-06-10 12:00:13 +00:00
ElementRobot a46ad75440 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 (#4868) (#4869)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 12:52:35 +01:00
renovate[bot] 1e80538cfb Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 (#4868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 11:30:04 +00:00
Michael Telatynski 73cbcfa4ee Ensure we send spec-compliant filter strings by stripping out null values (#4865)
* Ensure we send spec-compliant filter strings by stripping out null values

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-09 08:32:42 +00:00
Richard van der Hoff 99972ce0a9 Factor out common code for processing to-device events (#4863)
`sliding-sync-sdk.ts` and `sync.ts` both have copies of this code, and it's
redundant.
2025-06-05 08:57:50 +00:00
Timo 44399f6017 Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) (#4861)
* add test run helper to allow running long tests in vs code

* deprecate IDeferred (as its associated defer method is also deprecated and its just a type rename to PromiseWithResolvers)

* Improve docs and readability of MembershipManager.spec.ts

* Intoduce test for a race condition which results in a state where the state event and the hasMemberStateEvent variable diverge

* fix room state and membership manager state diverging. See:
https://github.com/element-hq/element-call-rageshakes/issues/10609
https://github.com/element-hq/element-call-rageshakes/issues/10594
https://github.com/element-hq/element-call-rageshakes/issues/9902

* logging, docstings and variable name improvements

* review

* review pending timers
2025-06-04 10:44:12 +00:00
RiotRobot c387f30e5c Merge branch 'master' into develop 2025-06-03 14:50:36 +00:00
RiotRobot eb9867a5ba v37.7.0 2025-06-03 14:50:05 +00:00
rsb-tbg 12a9875c46 Include extraParams in all HTTP requests (#4860)
* attaching queryParams from client config in getUrl

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

* changed client queryParams to QueryDict for consistency and now merging both sets of params in getUrl if one or both exist

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

* added tests

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

---------

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>
2025-05-30 09:09:21 +00:00
renovate[bot] 74f5efc4ef Update all non-major dependencies (#4859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 08:01:03 +00:00
renovate[bot] 43d47982ed Update dependency @types/node to v18.19.105 (#4858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 07:47:37 +00:00
renovate[bot] 94fb489952 Update dependency @stylistic/eslint-plugin to v4.4.0 (#4855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 15:36:54 +00:00
renovate[bot] 75ae05e5eb Update typescript-eslint monorepo to v8.33.0 (#4856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:45:13 +00:00
renovate[bot] 9058b79c39 Update all non-major dependencies (#4854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:44:33 +00:00
renovate[bot] 671dd2ca40 Update dependency typedoc to v0.28.5 (#4852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:44:13 +00:00
renovate[bot] 34f35393ff Update dependency @types/node to v18.19.103 (#4851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:43:50 +00:00
renovate[bot] e206a12902 Update babel monorepo to v7.27.3 (#4850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:43:26 +00:00
176 changed files with 15297 additions and 6339 deletions
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # 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@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@59cbc563d11314e48122193f8fe5cdda62ea6cf9 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+3
View File
@@ -41,3 +41,6 @@
- name: "Z-Flaky-Test"
description: "A test is raising false alarms"
color: "ededed"
- name: "Z-Skip-Coverage"
description: "Skip SonarQube coverage for this PR"
color: "ededed"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+7 -5
View File
@@ -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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -49,7 +49,7 @@ jobs:
pull-requests: write
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
if: github.event.pull_request.user.login != 'renovate[bot]' && github.event.pull_request.user.login != 'dependabot[bot]'
uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
id: teams
with:
@@ -60,7 +60,7 @@ jobs:
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
github.rest.issues.addLabels({
@@ -73,13 +73,15 @@ jobs:
close-if-fork-develop:
name: Forbid develop branch fork contributions
runs-on: ubuntu-24.04
permissions:
pull-requests: write
if: >
github.event.action == 'opened' &&
github.event.pull_request.head.ref == 'develop' &&
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
github.rest.issues.createComment({
+1 -1
View File
@@ -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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
REPO: ${{ inputs.repository }}
with:
@@ -16,12 +16,12 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ref: staging
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version-file: package.json
cache: "yarn"
@@ -37,7 +37,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
+3 -3
View File
@@ -18,14 +18,14 @@ jobs:
merge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
+6 -10
View File
@@ -4,8 +4,6 @@ on:
secrets:
ELEMENT_BOT_TOKEN:
required: true
NPM_TOKEN:
required: false
GPG_PASSPHRASE:
required: false
GPG_PRIVATE_KEY:
@@ -73,7 +71,7 @@ jobs:
draft: true
latest: true
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
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 +79,7 @@ jobs:
fetch-depth: 0
- name: Get actions scripts
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -106,7 +104,7 @@ jobs:
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { VERSION } = process.env;
@@ -125,7 +123,7 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -218,7 +216,7 @@ jobs:
- name: Validate release has expected assets
if: inputs.expected-asset-count
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
@@ -246,7 +244,7 @@ jobs:
git push origin master
- name: Publish release
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }}
@@ -282,8 +280,6 @@ jobs:
permissions:
contents: read
id-token: write
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
post-release:
name: Post release steps
+8 -14
View File
@@ -1,9 +1,6 @@
name: Publish to npm
on:
workflow_call:
secrets:
NPM_TOKEN:
required: true
outputs:
id:
description: "The npm package@version string we published"
@@ -20,32 +17,29 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ref: staging
- name: 🔧 Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
registry-url: "https://registry.npmjs.org"
node-version-file: package.json
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
run: |
npm publish --provenance --access public --tag next
npm publish --provenance --access public --tag "$TAG"
release=$(jq -r '"\(.name)@\(.version)"' package.json)
echo "id=$release" >> $GITHUB_OUTPUT
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: 🎖️ Add `latest` dist-tag to final releases
if: steps.npm-publish.outputs.id && !contains(steps.npm-publish.outputs.id, '-rc.')
run: npm dist-tag add "$release" latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.id }}
TAG: ${{ contains(steps.npm-publish.outputs.id, '-rc.') && 'next' || 'latest' }}
+5 -5
View File
@@ -44,13 +44,13 @@ jobs:
repo:
- element-hq/element-web
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version: "lts/*"
@@ -73,10 +73,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: 🔧 Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -88,7 +88,7 @@ jobs:
run: yarn gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: _docs
+7 -6
View File
@@ -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@5f2b01ce1394109f70954ae6b69ef41cf7928e63
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -36,14 +36,14 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -75,19 +75,20 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@820f7c2e9e94ba9e35add0f739691e5c7e23fa25 # v4.0
uses: matrix-org/sonarcloud-workflow-action@9d9692c97a426ea43f5b578c6bc8f952a867cd49
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
skip_checkout: true
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
skip_coverage_label: Z-Skip-Coverage
version_cmd: "cat package.json | jq -r .version"
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+16 -16
View File
@@ -14,9 +14,9 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -31,9 +31,9 @@ jobs:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version-file: package.json
@@ -61,7 +61,7 @@ jobs:
- name: Build Types
run: "yarn build:types"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: docs
path: _docs
@@ -125,9 +125,9 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
repository: element-hq/element-web
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version: "lts/*"
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node
id: setupNode
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "yarn"
node-version: ${{ matrix.node }}
@@ -57,7 +57,7 @@ jobs:
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
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@5f2b01ce1394109f70954ae6b69ef41cf7928e63
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
with:
operations-per-run: 250
days-before-issue-stale: -1
+1 -2
View File
@@ -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
+249
View File
@@ -1,3 +1,252 @@
Changes in [40.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.0.0) (2026-01-13)
==================================================================================================
## 🚨 BREAKING CHANGES
* MatrixRTC Pseudonymous livekit identities ([#5110](https://github.com/matrix-org/matrix-js-sdk/pull/5110)). Contributed by @toger5.
## 🦖 Deprecations
* Mark `forwardingCurve25519KeyChain` as deprecated ([#5111](https://github.com/matrix-org/matrix-js-sdk/pull/5111)). Contributed by @richvdh.
* Mark `IEventDecryptionResult` as deprecated ([#5112](https://github.com/matrix-org/matrix-js-sdk/pull/5112)). Contributed by @richvdh.
## ✨ Features
* Implement MSC4387: M\_SAFETY error ([#5107](https://github.com/matrix-org/matrix-js-sdk/pull/5107)). Contributed by @Half-Shot.
* Implement \_unstable\_getRTCTransports for MSC4143 ([#5104](https://github.com/matrix-org/matrix-js-sdk/pull/5104)). Contributed by @Half-Shot.
* Use `membershipID` for session events ([#5105](https://github.com/matrix-org/matrix-js-sdk/pull/5105)). Contributed by @toger5.
## 🐛 Bug Fixes
* Make MatrixRTC encryption key types narrower for TS 5.9 compatibility ([#5117](https://github.com/matrix-org/matrix-js-sdk/pull/5117)). Contributed by @robintown.
* Re-check outgoing requests after processing them ([#5109](https://github.com/matrix-org/matrix-js-sdk/pull/5109)). Contributed by @andybalaam.
* Make token refresher init itself lazily ([#5106](https://github.com/matrix-org/matrix-js-sdk/pull/5106)). Contributed by @dbkr.
Changes in [39.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.4.0) (2025-12-16)
==================================================================================================
## ✨ Features
* Import room key bundles received after invite. ([#5080](https://github.com/matrix-org/matrix-js-sdk/pull/5080)). Contributed by @kaylendog.
## 🐛 Bug Fixes
* Allow msc4354\_sticky\_key to be optional on sticky events. ([#5073](https://github.com/matrix-org/matrix-js-sdk/pull/5073)). Contributed by @Half-Shot.
* Handle all response fields from /context API being optional ([#5089](https://github.com/matrix-org/matrix-js-sdk/pull/5089)). Contributed by @t3chguy.
Changes in [39.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.3.0) (2025-12-02)
==================================================================================================
## 🐛 Bug Fixes
* Re-add truthy check on room name/avatar/alias events ([#5081](https://github.com/matrix-org/matrix-js-sdk/pull/5081)). Contributed by @t3chguy.
* Fix invalid state events corrupting room objects ([#5078](https://github.com/matrix-org/matrix-js-sdk/pull/5078)). Contributed by @t3chguy.
Changes in [39.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.2.0) (2025-11-18)
==================================================================================================
## ✨ Features
* Delayed event management: split endpoints, no auth ([#5066](https://github.com/matrix-org/matrix-js-sdk/pull/5066)). Contributed by @AndrewFerr.
* do not set cache in authenticated fetch ([#5020](https://github.com/matrix-org/matrix-js-sdk/pull/5020)). Contributed by @pkuzco.
## 🐛 Bug Fixes
* Fix media switching during legacy calls ([#5069](https://github.com/matrix-org/matrix-js-sdk/pull/5069)). Contributed by @langleyd.
Changes in [39.1.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.2) (2025-11-04)
==================================================================================================
Re-release of v39.1.0 to fix npm publishing workflow
Changes in [39.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.1) (2025-11-04)
==================================================================================================
Re-release of v39.1.0 to fix npm publishing workflow
Changes in [39.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.1.0) (2025-11-04)
==================================================================================================
## ✨ Features
* [MatrixRTC] Sticky Events support (MSC4354) ([#5017](https://github.com/matrix-org/matrix-js-sdk/pull/5017)). Contributed by @toger5.
* Add `CryptoApi.getSecretStorageStatus` ([#5054](https://github.com/matrix-org/matrix-js-sdk/pull/5054)). Contributed by @richvdh.
* Add parseCallNotificationContent ([#5015](https://github.com/matrix-org/matrix-js-sdk/pull/5015)). Contributed by @toger5.
* MSC4140: support filters on delayed event lookup ([#5038](https://github.com/matrix-org/matrix-js-sdk/pull/5038)). Contributed by @AndrewFerr.
Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21)
==================================================================================================
## 🚨 BREAKING CHANGES
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
## ✨ Features
* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5.
* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot.
* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco.
## 🐛 Bug Fixes
* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy.
* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam.
Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07)
==================================================================================================
## ✨ Features
* Add call intent to RTC call notifications ([#5010](https://github.com/matrix-org/matrix-js-sdk/pull/5010)). Contributed by @Half-Shot.
* Implement experimental encrypted state events. ([#4994](https://github.com/matrix-org/matrix-js-sdk/pull/4994)). Contributed by @kaylendog.
## 🐛 Bug Fixes
* Exclude cancelled requests from in-progress lists ([#5016](https://github.com/matrix-org/matrix-js-sdk/pull/5016)). Contributed by @andybalaam.
Changes in [38.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.3.0) (2025-09-23)
==================================================================================================
## 🐛 Bug Fixes
* Remove knocked room when membership changes to join ([#4977](https://github.com/matrix-org/matrix-js-sdk/pull/4977)). Contributed by @svajunas-budrys.
Changes in [38.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.2.0) (2025-09-16)
==================================================================================================
Fix [CVE-2025-59160](https://www.cve.org/CVERecord?id=CVE-2025-59160) / [GHSA-mp7c-m3rh-r56v](https://github.com/matrix-org/matrix-js-sdk/security/advisories/GHSA-mp7c-m3rh-r56v)
Changes in [38.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.1.0) (2025-09-09)
==================================================================================================
## ✨ Features
* Remove custom `org.matrix.msc4075.rtc.notification.parent` relation type ([#4979](https://github.com/matrix-org/matrix-js-sdk/pull/4979)). Contributed by @toger5.
* MatrixRTC: Add RTC decline event ([#4978](https://github.com/matrix-org/matrix-js-sdk/pull/4978)). Contributed by @toger5.
* Make a MatrixRTCSession emit once the RTCNotification is sent ([#4976](https://github.com/matrix-org/matrix-js-sdk/pull/4976)). Contributed by @toger5.
* Use hydra semantics for unknown room versions ([#4957](https://github.com/matrix-org/matrix-js-sdk/pull/4957)). Contributed by @dbkr.
* Expose the StatusChanged event through the RTCSession ([#4974](https://github.com/matrix-org/matrix-js-sdk/pull/4974)). Contributed by @toger5.
* Add `probablyLeft` event to the MatrixRTCSession ([#4962](https://github.com/matrix-org/matrix-js-sdk/pull/4962)). Contributed by @toger5.
## 🐛 Bug Fixes
* Fix `m.topic` format ([#4984](https://github.com/matrix-org/matrix-js-sdk/pull/4984)). Contributed by @tulir.
* Fix stable-suffixed MSC4133 support ([#4983](https://github.com/matrix-org/matrix-js-sdk/pull/4983)). Contributed by @dbkr.
* Fix thread edit aggregation race condition ([#4980](https://github.com/matrix-org/matrix-js-sdk/pull/4980)). Contributed by @basnijholt.
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
+16
View File
@@ -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
+3 -7
View File
@@ -71,7 +71,7 @@ Unless otherwise specified, the following applies to all code:
11. If a variable is not receiving a value on declaration, its type must be defined.
```typescript
let errorMessage: Optional<string>;
let errorMessage: string;
```
12. Objects can use shorthand declarations, including mixing of types.
@@ -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:
@@ -151,9 +150,7 @@ Unless otherwise specified, the following applies to all code:
1. When using `any`, a comment explaining why must be present.
27. `import` should be used instead of `require`, as `require` does not have types.
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.
29. Prefer a type like `X | null` 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
@@ -163,7 +160,7 @@ Unless otherwise specified, the following applies to all code:
```typescript
function doThingWithRoom(
thing: string,
room: Optional<string>, // require the caller to specify
room: string | null, // require the caller to specify
) {
// ...
}
@@ -272,7 +269,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

+1
View File
@@ -23,6 +23,7 @@ const config: Config = {
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
transformIgnorePatterns: ["/node_modules/(?!(uuid|p-retry|is-network-error)).+$"],
// Always print out a summary if there are any failing tests. Normally
// a summary is only printed if there are more than 20 test *suites*.
+2 -4
View File
@@ -12,10 +12,6 @@ export default {
"src/utils.ts", // not really an entrypoint but we have deprecated `defer` there
"scripts/**",
"spec/**",
// XXX: these look entirely unused
"src/crypto/aes.ts",
"src/crypto/crypto.ts",
"src/crypto/recoverykey.ts",
// XXX: these should be re-exported by one of the supported exports
"src/matrixrtc/index.ts",
"src/sliding-sync.ts",
@@ -38,6 +34,8 @@ export default {
"ts-node",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
// Transitive dep of jest
"jsdom",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
+21 -22
View File
@@ -1,16 +1,15 @@
{
"name": "matrix-js-sdk",
"version": "37.7.0-rc.0",
"version": "40.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"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,20 +49,19 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^14.2.0",
"@matrix-org/olm": "3.2.15",
"@matrix-org/matrix-sdk-crypto-wasm": "^16.0.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.10.0",
"matrix-widget-api": "^1.14.0",
"oidc-client-ts": "^3.0.1",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"p-retry": "7",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "11"
"uuid": "13"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -80,17 +78,18 @@
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@casualbot/jest-sonar-reporter": "2.5.0",
"@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/jest": "^30.0.0",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"babel-jest": "^29.0.0",
"babel-jest": "^30.0.0",
"babel-plugin-search-and-replace": "^1.1.1",
"debug": "^4.3.4",
"eslint": "8.57.1",
@@ -98,25 +97,25 @@
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^50.0.0",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-jest": "^29.0.0",
"eslint-plugin-jsdoc": "^61.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.4.0",
"eslint-plugin-tsdoc": "^0.5.0",
"eslint-plugin-unicorn": "^56.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "11.1.5",
"fetch-mock-jest": "^1.5.1",
"husky": "^9.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"jest-mock": "^30.0.0",
"knip": "^5.0.0",
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.5.3",
"prettier": "3.7.0",
"rimraf": "^6.0.0",
"ts-node": "^10.9.2",
"typedoc": "^0.28.1",
-3
View File
@@ -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";
+13 -11
View File
@@ -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),
@@ -404,7 +403,7 @@ describe("cross-signing", () => {
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
expect(isCrossSigningReady).toBeFalsy();
});
}, 10000);
});
describe("getCrossSigningKeyId", () => {
@@ -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 },
);
});
+22 -127
View File
@@ -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,
@@ -88,7 +88,10 @@ import {
encryptMegolmEventRawPlainText,
establishOlmSession,
getTestOlmAccountKeys,
} from "./olm-utils";
expectSendRoomKey,
expectSendMegolmMessageEvent,
expectEncryptedSendMessageEvent,
} from "./olm-utils.ts";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
import { KnownMembership } from "../../../src/@types/membership";
@@ -104,117 +107,7 @@ afterEach(() => {
jest.useRealTimers();
});
/**
* Expect that the client shares keys with the given recipient
*
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
* to establish an Olm InboundGroupSession.
*
* @param recipientUserID - the user id of the expected recipient
*
* @param recipientOlmAccount - Olm.Account for the recipient
*
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
*
* @returns the established inbound group session
*/
async function expectSendRoomKey(
recipientUserID: string,
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 {
const m = content.messages[recipientUserID].DEVICE_ID;
const ct = m.ciphertext[testRecipientKey];
if (!recipientOlmSession) {
expect(ct.type).toEqual(0); // pre-key message
recipientOlmSession = new Olm.Session();
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
} else {
expect(ct.type).toEqual(1); // regular message
}
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
expect(decrypted.type).toEqual("m.room_key");
const inboundGroupSession = new Olm.InboundGroupSession();
inboundGroupSession.create(decrypted.content.session_key);
return inboundGroupSession;
}
return await new Promise<Olm.InboundGroupSession>((resolve) => {
fetchMock.putOnce(
new RegExp("/sendToDevice/m.room.encrypted/"),
(url: string, opts: RequestInit): FetchMock.MockResponse => {
const content = JSON.parse(opts.body as string);
resolve(onSendRoomKey(content));
return {};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
}
/**
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
* @returns the content of the encrypted event
*/
function expectEncryptedSendMessage() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
return { event_id: "$event_id" };
},
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
{ overwriteRoutes: false },
);
});
}
/**
* Expect that the client sends an encrypted event
*
* Waits for an HTTP request to send an encrypted message in the test room.
*
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
*
* @returns The content of the successfully-decrypted event
*/
async function expectSendMegolmMessage(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedMessageContent = await expectEncryptedSendMessage();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
logger.log("Decrypted received megolm message", r);
return JSON.parse(r.plaintext);
}
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 = "";
@@ -1000,7 +893,7 @@ describe("crypto", () => {
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
]);
});
@@ -1027,7 +920,7 @@ describe("crypto", () => {
// Send the first message, and check we can decrypt it.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
expectSendMegolmMessageEvent(inboundGroupSessionPromise),
]);
// Finally the interesting part: discard the session.
@@ -1035,7 +928,7 @@ describe("crypto", () => {
// Now when we send the next message, we should get a *new* megolm session.
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2);
const p2 = expectSendMegolmMessageEvent(inboundGroupSessionPromise2);
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
});
@@ -1046,7 +939,7 @@ describe("crypto", () => {
*/
async function sendEncryptedMessage(): Promise<IContent> {
const [encryptedMessage] = await Promise.all([
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
aliceClient.sendTextMessage(ROOM_ID, "test"),
]);
return encryptedMessage;
@@ -1168,7 +1061,7 @@ describe("crypto", () => {
let [, , encryptedMessage] = await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
// Check that the session id exists
@@ -1196,7 +1089,7 @@ describe("crypto", () => {
[, , encryptedMessage] = await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
// Check that the new session id exists
@@ -1394,7 +1287,7 @@ describe("crypto", () => {
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
// kick it off
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
@@ -1417,7 +1310,7 @@ describe("crypto", () => {
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise);
const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise);
// kick it off
const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test");
@@ -1500,8 +1393,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");
});
},
);
@@ -2307,7 +2202,7 @@ describe("crypto", () => {
await syncPromise(client1);
// Send a message, and expect to get an `m.room.encrypted` event.
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]);
await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessageEvent()]);
// We now replace the client, and allow the new one to resync, *without* the encryption event.
client2 = await replaceClient(client1);
@@ -2328,7 +2223,7 @@ describe("crypto", () => {
// Send a message, and expect to get an `m.room.encrypted` event.
const [, msg1Content] = await Promise.all([
client1.sendTextMessage(ROOM_ID, "test1"),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
// Replace the state with one which bumps the rotation period. This should be ignored, though it's not
@@ -2347,12 +2242,12 @@ describe("crypto", () => {
// use a different one.
const [, msg2Content] = await Promise.all([
client1.sendTextMessage(ROOM_ID, "test2"),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
expect(msg2Content.session_id).toEqual(msg1Content.session_id);
const [, msg3Content] = await Promise.all([
client1.sendTextMessage(ROOM_ID, "test3"),
expectEncryptedSendMessage(),
expectEncryptedSendMessageEvent(),
]);
expect(msg3Content.session_id).not.toEqual(msg1Content.session_id);
});
@@ -2364,7 +2259,7 @@ describe("crypto", () => {
await syncPromise(client1);
// Send a message, and expect to get an `m.room.encrypted` event.
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]);
await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessageEvent()]);
// We now replace the client, and allow the new one to resync with a *different* encryption event.
client2 = await replaceClient(client1);
+3 -3
View File
@@ -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()!;
+340
View File
@@ -0,0 +1,340 @@
/*
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, waitFor } 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 () => {
// Reset mocks.
fetchMock.reset();
// 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!");
});
test("Room keys are imported correctly if invite is accepted before the bundle arrives", 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: "Hello!" });
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 room invite, but not the room key bundle
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] } } } },
});
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,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
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.isDecryptionFailure()).toBeTruthy();
// Now the room key bundle message arrives
fetchMock.getOnce(
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
{ body: uploadedBlob },
{ sendAsJson: false },
);
bobSyncResponder.sendOrQueueSyncResponse({
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
// Once the room key bundle finishes downloading, we should be able to decrypt the message.
await waitFor(async () => {
await event.getDecryptionPromise();
expect(event.isDecryptionFailure()).toBeFalsy();
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual("Hello!");
});
});
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 {};
},
);
});
}
+155 -4
View File
@@ -16,6 +16,7 @@ limitations under the License.
import Olm from "@matrix-org/olm";
import anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import {
type IContent,
@@ -30,6 +31,8 @@ import { type IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { type ISyncResponder } from "../../test-utils/SyncResponder";
import { syncPromise } from "../../test-utils/test-utils";
import { type KeyBackupInfo } from "../../../src/crypto-api";
import { logger } from "../../../src/logger";
import type FetchMock from "fetch-mock";
/**
* @module
@@ -93,15 +96,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 +192,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;
}
@@ -302,6 +305,9 @@ export function encryptMegolmEventRawPlainText(opts: {
},
type: "m.room.encrypted",
unsigned: {},
state_key: opts.plaintext.hasOwnProperty("state_key")
? `${opts.plaintext.type}:${opts.plaintext.state_key}`
: undefined,
};
}
@@ -414,3 +420,148 @@ export async function establishOlmSession(
await syncPromise(testClient);
return p2pSession;
}
/**
* Expect that the client shares keys with the given recipient
*
* Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it
* to establish an Olm InboundGroupSession.
*
* @param recipientUserID - the user id of the expected recipient
*
* @param recipientOlmAccount - Olm.Account for the recipient
*
* @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key
* messages with the sender. Alternatively, null, in which case we will expect a pre-key message.
*
* @returns the established inbound group session
*/
export async function expectSendRoomKey(
recipientUserID: string,
recipientOlmAccount: Olm.Account,
recipientOlmSession: Olm.Session | null = null,
): Promise<Olm.InboundGroupSession> {
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
function onSendRoomKey(content: any): Olm.InboundGroupSession {
const m = content.messages[recipientUserID].DEVICE_ID;
const ct = m.ciphertext[testRecipientKey];
if (!recipientOlmSession) {
expect(ct.type).toEqual(0); // pre-key message
recipientOlmSession = new Olm.Session();
recipientOlmSession.create_inbound(recipientOlmAccount, ct.body);
} else {
expect(ct.type).toEqual(1); // regular message
}
const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body));
expect(decrypted.type).toEqual("m.room_key");
const inboundGroupSession = new Olm.InboundGroupSession();
inboundGroupSession.create(decrypted.content.session_key);
return inboundGroupSession;
}
return await new Promise<Olm.InboundGroupSession>((resolve) => {
fetchMock.putOnce(
new RegExp("/sendToDevice/m.room.encrypted/"),
(url: string, opts: RequestInit): FetchMock.MockResponse => {
const content = JSON.parse(opts.body as string);
resolve(onSendRoomKey(content));
return {};
},
{
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
overwriteRoutes: false,
},
);
});
}
/**
* Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid
* @returns the content of the encrypted event
*/
export function expectEncryptedSendMessageEvent() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
return { event_id: "$event_id" };
},
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
{ overwriteRoutes: false },
);
});
}
/**
* Return the event received on rooms/{roomId}/state/m.room.encrypted/{stateKey} endpoint.
* See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey
* @returns the content of the encrypted event
*/
function expectEncryptedSendStateEvent() {
return new Promise<IContent>((resolve) => {
fetchMock.putOnce(
new RegExp("/state/m.room.encrypted/"),
(url, request) => {
const content = JSON.parse(request.body as string);
resolve(content);
return { event_id: "$event_id" };
},
// append to the list of intercepts on this path (since we have some tests that call
// this function multiple times)
{ overwriteRoutes: false },
);
});
}
/**
* Expect that the client sends an encrypted message event
*
* Waits for an HTTP request to send an encrypted message in the test room.
*
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
*
* @returns The content of the successfully-decrypted event
*/
export async function expectSendMegolmMessageEvent(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedMessageContent = await expectEncryptedSendMessageEvent();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext);
logger.log("Decrypted received megolm message", r);
return JSON.parse(r.plaintext);
}
/**
* Expect that the client sends an encrypted state event
*
* Waits for an HTTP request to send an encrypted state event in the test room.
*
* @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will
* be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed.
*
* @returns The content of the successfully-decrypted state event
*/
export async function expectSendMegolmStateEvent(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedStateContent = await expectEncryptedSendStateEvent();
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
const inboundGroupSession = await inboundGroupSessionPromise;
const r: any = inboundGroupSession.decrypt(encryptedStateContent!.ciphertext);
logger.log("Decrypted received megolm state event", r);
return JSON.parse(r.plaintext);
}
+35
View File
@@ -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);
});
});
+219
View File
@@ -0,0 +1,219 @@
/*
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 anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import Olm from "@matrix-org/olm";
import * as testUtils from "../../test-utils/test-utils";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
import { logger } from "../../../src/logger";
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import {
createOlmAccount,
createOlmSession,
encryptGroupSessionKey,
encryptMegolmEvent,
getTestOlmAccountKeys,
expectSendRoomKey,
expectSendMegolmStateEvent,
} from "./olm-utils";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
describe("Encrypted State Events", () => {
let testOlmAccount = {} as unknown as Olm.Account;
let testSenderKey = "";
/** the MatrixClient under test */
let aliceClient: MatrixClient;
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
let keyReceiver: E2EKeyReceiver;
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
let syncResponder: ISyncResponder;
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
logger.log(aliceClient.getUserId() + ": starting");
mockInitialApiRequests(aliceClient.getHomeserverUrl());
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
aliceClient.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached,
...opts,
});
await syncPromise(aliceClient);
logger.log(aliceClient.getUserId() + ": started");
}
beforeEach(async () => {
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: "@alice:localhost",
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
logger: logger.getChild("aliceClient"),
enableEncryptedStateEvents: true,
});
keyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
await aliceClient.initRustCrypto();
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
testOlmAccount = await createOlmAccount();
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
testSenderKey = testE2eKeys.curve25519;
}, 10000);
afterEach(async () => {
await aliceClient.stopClient();
await jest.runAllTimersAsync();
fetchMock.mockReset();
});
function expectAliceKeyQuery(response: any) {
fetchMock.postOnce(new RegExp("/keys/query"), (url: string, opts: RequestInit) => response, {
overwriteRoutes: false,
});
}
function expectAliceKeyClaim(response: any) {
fetchMock.postOnce(new RegExp("/keys/claim"), response);
}
function getTestKeysClaimResponse(userId: string) {
testOlmAccount.generate_one_time_keys(1);
const testOneTimeKeys = JSON.parse(testOlmAccount.one_time_keys());
testOlmAccount.mark_keys_as_published();
const keyId = Object.keys(testOneTimeKeys.curve25519)[0];
const oneTimeKey: string = testOneTimeKeys.curve25519[keyId];
const unsignedKeyResult = { key: oneTimeKey };
const j = anotherjson.stringify(unsignedKeyResult);
const sig = testOlmAccount.sign(j);
const keyResult = {
...unsignedKeyResult,
signatures: { [userId]: { "ed25519:DEVICE_ID": sig } },
};
return {
one_time_keys: { [userId]: { DEVICE_ID: { ["signed_curve25519:" + keyId]: keyResult } } },
failures: {},
};
}
it("Should receive an encrypted state event", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// make the room_key event
const roomKeyEncrypted = encryptGroupSessionKey({
recipient: aliceClient.getUserId()!,
recipientCurve25519Key: keyReceiver.getDeviceKey(),
recipientEd25519Key: keyReceiver.getSigningKey(),
olmAccount: testOlmAccount,
p2pSession: p2pSession,
groupSession: groupSession,
room_id: ROOM_ID,
});
// encrypt a state event with the group session
const eventEncrypted = encryptMegolmEvent({
senderKey: testSenderKey,
groupSession: groupSession,
room_id: ROOM_ID,
plaintext: {
type: "m.room.topic",
state_key: "",
content: {
topic: "Secret!",
},
},
});
// Alice gets both the events in a single sync
const syncResponse = {
next_batch: 1,
to_device: {
events: [roomKeyEncrypted],
},
rooms: {
join: {
[ROOM_ID]: { timeline: { events: [eventEncrypted] } },
},
},
};
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
expect(event.isEncrypted()).toBe(true);
// it probably won't be decrypted yet, because it takes a while to process the olm keys
const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(decryptedEvent.getContent().topic).toEqual("Secret!");
});
it("Should send an encrypted state event", async () => {
const homeserverUrl = aliceClient.getHomeserverUrl();
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
keyResponder.addDeviceKeys(testDeviceKeys);
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
await syncPromise(aliceClient);
// ... and claim one of Bob's OTKs ...
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// ... and send an m.room.topic message
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
// Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt.
await Promise.all([
aliceClient.setRoomTopic(ROOM_ID, "Secret!"),
expectSendMegolmStateEvent(inboundGroupSessionPromise),
]);
});
});
+121 -3
View File
@@ -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");
});
});
});
+84 -11
View File
@@ -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,8 +46,8 @@ import {
type Verifier,
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { escapeRegExp } from "../../../src/utils";
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { escapeRegExp, sleep } from "../../../src/utils";
import { awaitDecryption, emitPromise, getSyncResponse, syncPromise, waitFor } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
BACKUP_DECRYPTION_KEY_BASE64,
@@ -77,14 +79,9 @@ import {
import { type KeyBackupInfo, CryptoEvent } from "../../../src/crypto-api";
import { encodeBase64 } from "../../../src/base64";
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
beforeAll(async () => {
// we use the libolm primitives in the test, so init the Olm library
await globalThis.Olm.init();
await Olm.init();
});
// load the rust library. This can take a few seconds on a slow GH worker.
@@ -94,6 +91,13 @@ beforeAll(async () => {
await RustSdkCryptoJs.initAsync();
}, 10000);
beforeEach(() => {
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
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
@@ -110,7 +114,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 +257,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 +351,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");
@@ -734,6 +737,35 @@ describe("verification", () => {
expect(request.cancellingUserId).toEqual("@alice:localhost");
});
it("does not include cancelled requests in the list of requests", async () => {
// Given Alice started a verification request
const [, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
await waitForVerificationRequestChanged(request);
// Sanity: the request is listed
const requestsBeforeCancel = aliceClient
.getCrypto()!
.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requestsBeforeCancel).toHaveLength(1);
// When Alice cancels it
await Promise.all([expectSendToDeviceMessage("m.key.verification.cancel"), request.cancel()]);
// Then it is no longer listed as in progress
const requestsAfterCancel = aliceClient
.getCrypto()!
.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requestsAfterCancel).toHaveLength(0);
});
it("can cancel during the SAS phase", async () => {
// have alice initiate a verification. She should send a m.key.verification.request
const [, request] = await Promise.all([
@@ -1050,6 +1082,13 @@ describe("verification", () => {
});
it("ignores old verification requests", async () => {
const debug = jest.fn();
const info = jest.fn();
const warn = jest.fn();
// @ts-ignore overriding RustCrypto's logger
aliceClient.getCrypto()!.logger = { debug, info, warn };
const eventHandler = jest.fn();
aliceClient.on(CryptoEvent.VerificationRequestReceived, eventHandler);
@@ -1064,6 +1103,16 @@ describe("verification", () => {
const matrixEvent = room.getLiveTimeline().getEvents()[0];
expect(matrixEvent.getId()).toEqual(verificationRequestEvent.event_id);
// Wait until the request has been processed. We use a real sleep()
// here to make sure any background async tasks are completed.
jest.useRealTimers();
await waitFor(async () => {
expect(info).toHaveBeenCalledWith(
expect.stringMatching(/^Ignoring just-received verification request/),
);
sleep(100);
});
// check that an event has not been raised, and that the request is not found
expect(eventHandler).not.toHaveBeenCalled();
expect(
@@ -1071,6 +1120,29 @@ describe("verification", () => {
).not.toBeDefined();
});
it("ignores cancelled verification requests", async () => {
// Given a verification request exists
const event = createVerificationRequestEvent();
returnRoomMessageFromSync(TEST_ROOM_ID, event);
// Wait for the request to be received
await emitPromise(aliceClient, CryptoEvent.VerificationRequestReceived);
const request = aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz");
// When I cancel it
fetchMock.put("express:/_matrix/client/v3/rooms/:roomId/send/m.key.verification.cancel/:id", {
event_id: event.event_id,
});
await request!.cancel();
expect(request!.phase).toEqual(VerificationPhase.Cancelled);
// Then it is no longer found
expect(
aliceClient.getCrypto()!.findVerificationRequestDMInProgress(TEST_ROOM_ID, "@bob:xyz"),
).not.toBeDefined();
});
it("Plaintext verification request from Bob to Alice", async () => {
// Add verification request from Bob to Alice in the DM between them
returnRoomMessageFromSync(TEST_ROOM_ID, createVerificationRequestEvent());
@@ -1476,6 +1548,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();
@@ -672,7 +672,7 @@ describe("MatrixClient event timelines", function () {
expect(timeline!.getEvents().find((e) => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy();
});
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
it("should return null when event is not in the thread that the given timelineSet is representing", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
@@ -696,12 +696,12 @@ describe("MatrixClient event timelines", function () {
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(),
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeNull(),
httpBackend.flushAllExpected(),
]);
});
it("should return undefined when event is within a thread but timelineSet is not", () => {
it("should return null when event is within a thread but timelineSet is not", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
@@ -723,7 +723,7 @@ describe("MatrixClient event timelines", function () {
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(),
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeNull(),
httpBackend.flushAllExpected(),
]);
});
+53
View File
@@ -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";
+106 -1
View File
@@ -159,7 +159,7 @@ describe("MatrixClient opts", function () {
await expect(
Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]),
).rejects.toThrow("MatrixError: [500] Unknown message");
).rejects.toThrow("MatrixError: [500] Ruh roh");
});
it("shouldn't queue events", async () => {
@@ -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);
});
});
});
+1 -1
View File
@@ -126,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;
-27
View File
@@ -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);
}
+5
View File
@@ -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
}
+96 -7
View File
@@ -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];
}
}
+22 -15
View File
@@ -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;
+73
View File
@@ -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);
}
}
+1 -16
View File
@@ -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",
},
{},
);
}
/**
+110 -10
View File
@@ -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 {
@@ -63,7 +63,11 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
*
* @returns the sync response
*/
export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse {
export function getSyncResponse(
roomMembers: string[],
roomId = TEST_ROOM_ID,
encryptStateEvents = false,
): ISyncResponse {
const roomResponse: IJoinedRoom = {
summary: {
"m.heroes": [],
@@ -77,7 +81,8 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
"algorithm": "m.megolm.v1.aes-sha2",
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
},
}),
],
@@ -522,25 +527,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 +597,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."));
}
}
});
}
+2 -1
View File
@@ -5,6 +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 { logger } from "../../src/logger.ts";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
@@ -55,7 +56,7 @@ describe("onResumedSync", () => {
}
});
queue = new ToDeviceMessageQueue(mockClient);
queue = new ToDeviceMessageQueue(mockClient, logger);
});
it("resends queue after connectivity restored", async () => {
+70 -37
View File
@@ -183,39 +183,43 @@ describe("Topic content helpers", () => {
it("creates fully defined event content without html", () => {
expect(makeTopicContent("pizza")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
mimetype: "text/plain",
},
],
},
});
});
it("creates fully defined event content with html", () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
{
body: "pizza",
mimetype: "text/plain",
},
],
},
});
});
it("creates an empty event when the topic is falsey", () => {
expect(makeTopicContent(undefined)).toEqual({
topic: undefined,
[M_TOPIC.name]: [],
[M_TOPIC.name]: { "m.text": [] },
});
expect(makeTopicContent(null)).toEqual({
topic: null,
[M_TOPIC.name]: [],
[M_TOPIC.name]: { "m.text": [] },
});
});
});
@@ -225,11 +229,13 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -240,12 +246,14 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "pizza",
mimetype: "text/plain",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "pizza",
mimetype: "text/plain",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -256,12 +264,14 @@ describe("Topic content helpers", () => {
expect(
parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
],
[M_TOPIC.name]: {
"m.text": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
],
},
}),
).toEqual({
text: "pizza",
@@ -279,11 +289,34 @@ describe("Topic content helpers", () => {
});
});
it("uses legacy event content when new topic key is invalid", () => {
// TODO delete this test and re-enable the next one after support for the invalid form is removed
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
it("parses malformed event content with html topic", () => {
expect(
parseTopicContent({
"topic": "pizza",
"m.topic": {} as any,
"m.topic": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
] as any,
}),
).toEqual({
text: "pizza",
html: "<b>pizza</b>",
});
});
it.skip("uses legacy event content when new topic key is invalid", () => {
expect(
parseTopicContent({
"topic": "pizza",
"m.topic": [
{
body: "<b>pizza</b>",
mimetype: "text/html",
},
] as any,
}),
).toEqual({
text: "pizza",
+51 -10
View File
@@ -86,7 +86,9 @@ class MockWidgetApi extends EventEmitter {
? { event_id: `$${Math.random()}` }
: { delay_id: `id-${Math.random()}` },
);
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
public cancelScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public restartScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendScheduledDelayedEvent = jest.fn().mockResolvedValue(undefined);
public sendToDevice = jest.fn().mockResolvedValue(undefined);
public requestOpenIDConnectToken = jest.fn(async () => {
return testOIDCToken;
@@ -531,17 +533,49 @@ describe("RoomWidgetClient", () => {
).rejects.toThrow();
});
it("updates delayed events", async () => {
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in parameter)",
async (action: UpdateDelayedEventAction) => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(
MatrixCapabilities.MSC4157UpdateDelayedEvent,
);
await client._unstable_updateDelayedEvent("id", action);
let updateDelayedEvent: (delayId: string) => Promise<unknown>;
switch (action) {
case UpdateDelayedEventAction.Cancel:
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Restart:
updateDelayedEvent = widgetApi.cancelScheduledDelayedEvent;
break;
case UpdateDelayedEventAction.Send:
updateDelayedEvent = widgetApi.sendScheduledDelayedEvent;
break;
}
expect(updateDelayedEvent).toHaveBeenCalledWith("id");
},
);
it("can cancel scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
for (const action of [
UpdateDelayedEventAction.Cancel,
UpdateDelayedEventAction.Restart,
UpdateDelayedEventAction.Send,
]) {
await client._unstable_updateDelayedEvent("id", action);
expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action);
}
await client._unstable_cancelScheduledDelayedEvent("id");
expect(widgetApi.cancelScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
it("can restart scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
await client._unstable_restartScheduledDelayedEvent("id");
expect(widgetApi.restartScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
it("can send scheduled delayed events (action in method)", async () => {
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
await client._unstable_sendScheduledDelayedEvent("id");
expect(widgetApi.sendScheduledDelayedEvent).toHaveBeenCalledWith("id");
});
});
@@ -583,6 +617,13 @@ describe("RoomWidgetClient", () => {
"Server does not support",
);
}
for (const updateDelayedEvent of [
client._unstable_cancelScheduledDelayedEvent,
client._unstable_restartScheduledDelayedEvent,
client._unstable_sendScheduledDelayedEvent,
]) {
await expect(updateDelayedEvent.call(client, "id")).rejects.toThrow("Server does not support");
}
});
});
});
+7
View File
@@ -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"] });
});
});
});
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`MatrixHttpApi should return expected object from \`getContentUri\` 1`] = `
{
+198 -36
View File
@@ -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 {
@@ -43,8 +42,8 @@ describe("FetchHttpApi", () => {
});
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");
@@ -68,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");
@@ -82,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");
@@ -106,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 });
@@ -129,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();
@@ -170,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();
@@ -184,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" },
@@ -212,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" },
});
@@ -236,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),
@@ -246,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();
});
@@ -277,6 +336,7 @@ describe("FetchHttpApi", () => {
const okayResponse = {
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ x: 1 }),
};
describe("without a tokenRefreshFunction", () => {
@@ -284,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,
);
@@ -306,8 +373,9 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
@@ -327,8 +395,9 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toEqual(
unknownTokenErr,
);
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
@@ -355,11 +424,12 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
const result = await api.authedRequest(Method.Post, "/account/password", undefined, undefined, {
headers: {},
});
expect(result).toEqual(okayResponse);
expect(result).toEqual({ x: 1 });
expect(tokenRefreshFunction).toHaveBeenCalledWith(refreshToken);
expect(fetchFn).toHaveBeenCalledTimes(2);
@@ -398,6 +468,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -453,6 +524,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken,
refreshToken,
onlyData: true,
});
await expect(api.authedRequest(Method.Post, "/account/password")).rejects.toThrow(
unknownTokenErr,
@@ -473,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 = {
@@ -521,6 +593,89 @@ 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 () => {
@@ -535,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);
responseResolvers.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");
@@ -581,6 +737,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken: "ACCESS_TOKEN",
refreshToken: "REFRESH_TOKEN",
onlyData: true,
});
const prom1 = api.authedRequest(Method.Get, "/path1");
@@ -636,6 +793,7 @@ describe("FetchHttpApi", () => {
tokenRefreshFunction,
accessToken: "ACCESS_TOKEN",
refreshToken: "REFRESH_TOKEN",
onlyData: true,
});
const prom1 = api.authedRequest(Method.Get, "/path1");
@@ -673,3 +831,7 @@ describe("FetchHttpApi", () => {
expect(api.opts.refreshToken).toBe("NEW_REFRESH_TOKEN");
});
});
function makeMockFetchFn(): MockedFunction<any> {
return jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({}) });
}
+21 -14
View File
@@ -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();
});
});
+31
View File
@@ -21,6 +21,8 @@ import {
ConnectionError,
HTTPError,
MatrixError,
MatrixSafetyError,
MatrixSafetyErrorCode,
parseErrorResponse,
retryNetworkOperation,
timeoutSignal,
@@ -185,6 +187,35 @@ describe("parseErrorResponse", () => {
),
);
});
it.each([
{
errcode: MatrixSafetyErrorCode.name,
error: "Spammy",
},
{
errcode: MatrixSafetyErrorCode.name,
error: "Spammy",
expiry: 5000,
},
{
errcode: MatrixSafetyErrorCode.name,
error: "Spammy",
harms: ["m.spam", "org.example.additional-harm"],
expiry: 5000,
},
])("should resolve MatrixSafetyErrors from fetch", (errContent) => {
headers.set("Content-Type", "application/json");
const value = parseErrorResponse(
{
headers,
status: 400,
} as Response,
JSON.stringify(errContent),
) as MatrixSafetyError;
expect(value).toBeInstanceOf(MatrixSafetyError);
expect(value.harms.size).toEqual(errContent.harms?.length ?? 0);
expect(value.expiry?.getTime()).toEqual(errContent.expiry);
});
describe("with HTTP headers", () => {
function addHeaders(headers: Headers) {
+40 -1
View File
@@ -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);
});
});
+270 -27
View File
@@ -84,6 +84,7 @@ import { mockOpenIdConfiguration } from "../test-utils/oidc.ts";
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { SyncResponder } from "../test-utils/SyncResponder.ts";
import { mockInitialApiRequests } from "../test-utils/mockEndpoints.ts";
import { type Transport } from "src/matrixrtc/index.ts";
jest.useFakeTimers();
@@ -801,6 +802,10 @@ describe("MatrixClient", function () {
await expect(
client._unstable_updateDelayedEvent("anyDelayId", UpdateDelayedEventAction.Send),
).rejects.toThrow(errorMessage);
await expect(client._unstable_cancelScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
await expect(client._unstable_restartScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
await expect(client._unstable_sendScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage);
});
it("works with null threadId", async () => {
@@ -1053,34 +1058,193 @@ describe("MatrixClient", function () {
);
});
it("can look up delayed events", async () => {
describe("lookups", () => {
const statuses = [undefined, "scheduled" as const, "finalised" as const];
const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]];
const inputs = statuses.flatMap((status) =>
delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]),
);
it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => {
httpLookups = [
{
method: "GET",
prefix: unstableMSC4140Prefix,
path: "/delayed_events",
expectQueryParams: {
status,
delay_id: delayId,
},
data: [],
},
];
await client._unstable_getDelayedEvents(status, delayId);
});
});
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in request body)",
async (action: UpdateDelayedEventAction) => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
},
);
it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])(
"can %s scheduled delayed events (action in request body fallback when auth required)",
async (action: UpdateDelayedEventAction) => {
const delayId = "id";
const baseLookup = {
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
};
httpLookups = [
{
...baseLookup,
error: {
httpStatus: 401,
errcode: "M_MISSING_TOKEN",
},
},
{
...baseLookup,
data: {
action,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
},
);
it("can cancel scheduled delayed events (action in request path)", async () => {
const delayId = "id";
httpLookups = [
{
method: "GET",
method: "POST",
prefix: unstableMSC4140Prefix,
path: "/delayed_events",
data: [],
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
},
];
await client._unstable_getDelayedEvents();
await client._unstable_cancelScheduledDelayedEvent(delayId);
});
it("can update delayed events", async () => {
it("can restart scheduled delayed events (action in request path)", async () => {
const delayId = "id";
const action = UpdateDelayedEventAction.Restart;
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
},
];
await client._unstable_restartScheduledDelayedEvent(delayId);
});
it("can send scheduled delayed events (action in request path)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
},
];
await client._unstable_sendScheduledDelayedEvent(delayId);
});
it("can cancel scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action,
action: UpdateDelayedEventAction.Cancel,
},
},
];
await client._unstable_updateDelayedEvent(delayId, action);
await client._unstable_cancelScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
it("can restart scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/restart`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action: UpdateDelayedEventAction.Restart,
},
},
];
await client._unstable_restartScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
it("can send scheduled delayed events (action in request path fallback when unsupported)", async () => {
const delayId = "id";
httpLookups = [
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}/send`,
error: {
httpStatus: 400,
errcode: "M_UNRECOGNIZED",
},
},
{
method: "POST",
prefix: unstableMSC4140Prefix,
path: `/delayed_events/${encodeURIComponent(delayId)}`,
data: {
action: UpdateDelayedEventAction.Send,
},
},
];
await client._unstable_sendScheduledDelayedEvent(delayId);
expect(httpLookups).toHaveLength(0);
});
});
@@ -2366,6 +2530,61 @@ describe("MatrixClient", function () {
});
});
describe("disableVoip option", () => {
const baseUrl = "https://alice-server.com";
const userId = "@alice:bar";
const accessToken = "sometoken";
beforeEach(() => {
mocked(supportsMatrixCall).mockReturnValue(true);
});
afterAll(() => {
mocked(supportsMatrixCall).mockReset();
});
it("should not call /voip/turnServer when disableVoip = true", () => {
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, 200);
const client = createClient({
baseUrl,
accessToken,
userId,
disableVoip: true,
});
// Only check createCall / supportsVoip, avoid startClient
expect(client.createCall("!roomId:example.com")).toBeNull();
expect(client.supportsVoip?.()).toBe(false);
});
it("should call /voip/turnServer when disableVoip is not set", () => {
fetchMock.getOnce(`${baseUrl}/_matrix/client/unstable/voip/turnServer`, {
uris: ["turn:turn.example.org"],
});
createClient({
baseUrl,
accessToken,
userId,
});
// The call will trigger the request if VoIP is supported
expect(fetchMock.called(`${baseUrl}/_matrix/client/unstable/voip/turnServer`)).toBe(false);
});
it("should return null from createCall when disableVoip = true", () => {
const client = createClient({
baseUrl,
accessToken,
userId,
disableVoip: true,
});
expect(client.createCall("!roomId:example.com")).toBeNull();
});
});
describe("support for ignoring invites", () => {
beforeEach(() => {
// Mockup `getAccountData`/`setAccountData`.
@@ -2998,6 +3217,8 @@ describe("MatrixClient", function () {
replacedByDynamicPredecessor2,
room2,
];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room1.addLiveEvents(
[
roomCreateEvent(room1.roomId, replacedByCreate1.roomId),
@@ -3036,6 +3257,7 @@ describe("MatrixClient", function () {
replacedByDynamicPredecessor2,
};
}
it("Returns an empty list if there are no rooms", () => {
client.store = new StubStore();
client.store.getRooms = () => [];
@@ -3062,6 +3284,8 @@ describe("MatrixClient", function () {
const room2 = new Room("room2", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true });
room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], { addToState: true });
replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true });
@@ -3127,6 +3351,24 @@ describe("MatrixClient", function () {
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
it("should ignore room replacements which are not reciprocated by the predecessor", () => {
const room1 = new Room("room1", client, "@carol:alexandria.example.com");
// Room 2 claims to replace room 1 but room 1 does not agree
const room2 = new Room("replacedRoom1", client, "@daryl:alexandria.example.com");
client.store = new StubStore();
client.store.getRooms = () => [room1, room2];
client.store.getRoom = (roomId: string) =>
client.store.getRooms().find((r) => r.roomId === roomId) || null;
room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)], { addToState: true });
// When we ask for the visible rooms
const rooms = client.getVisibleRooms();
expect(rooms).toContain(room1);
expect(rooms).toContain(room2);
});
});
describe("getRoomUpgradeHistory", () => {
@@ -3597,24 +3839,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();
@@ -3712,4 +3936,23 @@ describe("MatrixClient", function () {
expect(lookupResult[0]).toEqual({ address: "bob@email.dummy", mxid: "@bob:homeserver.dummy" });
});
});
describe("_unstable_getRTCTransports", () => {
it("makes a well-formed request", async () => {
httpLookups = [
{
method: "GET",
path: `/rtc/transports`,
data: { rtc_transports: [{ type: "livekit", extra_field: "foobar" }] satisfies Transport[] },
prefix: "/_matrix/client/unstable/org.matrix.msc4143",
},
];
expect(await client._unstable_getRTCTransports()).toEqual([
{
type: "livekit",
extra_field: "foobar",
},
]);
});
});
});
+289 -19
View File
@@ -19,6 +19,7 @@ import {
CallMembership,
type SessionMembershipData,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks";
@@ -26,9 +27,17 @@ function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: jest.fn().mockReturnValue(originTs),
getSender: jest.fn().mockReturnValue("@alice:example.org"),
getId: jest.fn().mockReturnValue("$eventid"),
getContent: jest.fn().mockReturnValue({}),
} as unknown as MatrixEvent;
}
function createCallMembership(ev: MatrixEvent, content: unknown): CallMembership {
(ev.getContent as jest.Mock).mockReturnValue(content);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, "xx");
}
describe("CallMembership", () => {
describe("SessionMembershipData", () => {
beforeEach(() => {
@@ -40,39 +49,40 @@ describe("CallMembership", () => {
});
const membershipTemplate: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
focus_active: { type: "livekit" },
foci_preferred: [{ type: "livekit" }],
"call_id": "",
"scope": "m.room",
"application": "m.call",
"device_id": "AAAAAAA",
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
"foci_preferred": [{ type: "livekit" }],
"m.call.intent": "voice",
};
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
const membership = createCallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
const membership = createCallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
@@ -82,23 +92,283 @@ describe("CallMembership", () => {
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1));
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
});
it("considers memberships expired if local age large enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1));
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
});
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
const membership = createCallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
expect(membership.transports).toEqual([mockFocus]);
});
describe("getTransport", () => {
const mockFocus = { type: "this_is_a_mock_focus" };
const oldestMembership = createCallMembership(makeMockEvent(), membershipTemplate);
it("gets the correct active transport with oldest_membership", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we use its focus.
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
});
it("gets the correct active transport with multi_sfu", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we still use our own focus in multi sfu.
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
});
it("does not provide focus if the selection method is unknown", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "unknown" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toBeUndefined();
});
});
describe("correct values from computed fields", () => {
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
});
it("returns correct scope", () => {
expect(membership.scope).toBe("m.room");
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("@alice:example.org:AAAAAAA");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
expect(membership.isExpired()).toBe(true);
});
});
});
describe("RtcMembershipData", () => {
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
};
it("rejects membership with no slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.toThrow();
});
it("rejects membership with no application", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
}).toThrow();
});
it("rejects membership with incorrect application", () => {
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: { wrong_type_key: "unknown" },
});
}).toThrow();
});
it("rejects membership with no member", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
}).toThrow();
});
it("rejects membership with incorrect member", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@@test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
});
}).toThrow();
});
it("rejects membership with incorrect sticky_key", () => {
expect(() => {
createCallMembership(makeMockEvent(), membershipTemplate);
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: 1,
msc4354_sticky_key: undefined,
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: "1",
msc4354_sticky_key: undefined,
});
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: 1,
sticky_key: "valid",
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid",
sticky_key: "valid",
});
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid_but_different",
sticky_key: "valid",
});
}).toThrow();
});
it("considers memberships unexpired if local age low enough", () => {
// TODO link prev event
});
it("considers memberships expired if local age large enough", () => {
// TODO link prev event
});
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
const oldestMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
rtc_transports: [{ type: "oldest_transport" }],
});
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
});
});
describe("correct values from computed fields", () => {
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({
"type": "m.call",
"m.call.id": "",
"m.call.intent": "voice",
});
});
it("returns correct scope", () => {
expect(membership.scope).toBe(undefined);
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("xyzHASHxyz");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(undefined);
expect(membership.getMsUntilExpiry()).toBe(undefined);
expect(membership.isExpired()).toBe(false);
});
});
});
@@ -109,7 +379,7 @@ describe("CallMembership", () => {
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = new CallMembership(fakeEvent!, membershipTemplate);
membership = createCallMembership(fakeEvent!, membershipTemplate);
jest.useFakeTimers();
});
@@ -14,47 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
import {
isLivekitTransport,
isLivekitFocusSelection,
isLivekitTransportConfig,
} from "../../../src/matrixrtc/LivekitTransport";
describe("LivekitFocus", () => {
it("isLivekitFocus", () => {
expect(
isLivekitFocus({
isLivekitTransport({
type: "livekit",
livekit_service_url: "http://test.com",
livekit_alias: "test",
}),
).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
expect(isLivekitTransport({ type: "livekit" })).toBeFalsy();
expect(
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
).toBeFalsy();
});
it("isLivekitFocusActive", () => {
expect(
isLivekitFocusActive({
isLivekitFocusSelection({
type: "livekit",
focus_selection: "oldest_membership",
}),
).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
});
it("isLivekitFocusConfig", () => {
expect(
isLivekitFocusConfig({
isLivekitTransportConfig({
type: "livekit",
livekit_service_url: "http://test.com",
}),
).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
});
});
File diff suppressed because it is too large Load Diff
@@ -14,59 +14,149 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mock } from "jest-mock";
import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc";
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
import { logger } from "../../../src/logger";
import { flushPromises } from "../../test-utils/flushPromises";
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";
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
"MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
beforeEach(() => {
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
});
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
});
it("Fires event when session starts", () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
if (eventKind === "memberState") {
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
} else {
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
await flushPromises();
}
});
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)),
beforeEach(() => {
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
});
const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("")[0];
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
});
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
it("Fires event when session starts", async () => {
const onStarted = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
});
try {
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
});
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" }], eventKind === "sticky");
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", async () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
await flushPromises();
await sendLeaveMembership(room1, membershipData);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Fires correctly with custom sessionDescription", async () => {
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",
});
// 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);
try {
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
const room2MembershipData: MembershipData[] = [
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
// Stop room1's RTC session. Tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// Stop room1's RTC session. Not tracked.
jest.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
});
it("Doesn't fire event if unrelated sessions ends", async () => {
const onEnded = jest.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
const room1 = makeMockRoom(membership, eventKind === "sticky");
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
await sendLeaveMembership(room1, membership);
expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
},
);
+391 -186
View File
@@ -1,6 +1,3 @@
/**
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
*/
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
@@ -19,58 +16,71 @@ limitations under the License.
import { type MockedFunction, type Mock } from "jest-mock";
import { EventType, HTTPError, MatrixError, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
import {
type EmptyObject,
EventType,
HTTPError,
MatrixError,
UnsupportedDelayedEventsEndpointError,
type Room,
MAX_STICKY_DURATION_MS,
} from "../../../src";
import {
MembershipManagerEvent,
Status,
type Focus,
type LivekitFocusActive,
type Transport,
type SessionMembershipData,
type LivekitFocusSelection,
} 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 { logger } from "../../../src/logger.ts";
import { MembershipManager, StickyEventMembershipManager } 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 } = Promise.withResolvers<void>();
/**
* 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 = {
const focusActive: LivekitFocusSelection = {
focus_selection: "oldest_membership",
type: "livekit",
};
const focus: Focus = {
const focus: Transport = {
type: "livekit",
livekit_service_url: "https://active.url",
livekit_alias: "!active:active.url",
@@ -80,11 +90,16 @@ 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);
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_cancelScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client.sendStateEvent as Mock<any>).mockResolvedValue({ event_id: "id" });
});
afterEach(() => {
@@ -94,12 +109,12 @@ describe.each([
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -110,13 +125,15 @@ 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 restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
// Test
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
memberManager.join([focus], focusActive);
const memberManager = new MembershipManager(undefined, room, client, callSession);
memberManager.join([focus], undefined);
// 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",
@@ -126,32 +143,33 @@ describe.each([
device_id: "AAAAAAA",
expires: 14400000,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
updateDelayedEventHandle.resolve?.();
restartScheduledDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ 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, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
const waitForRestartScheduledDelayedEvent = waitForMockCallOnce(
client._unstable_restartScheduledDelayedEvent,
Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
);
memberManager.join([focus], focusActive);
await waitForSendState;
await waitForUpdateDelaye;
await waitForRestartScheduledDelayedEvent;
await jest.advanceTimersByTimeAsync(1);
// Once for the initial event and once because of the errcode: "M_NOT_FOUND"
// Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1)
@@ -167,7 +185,7 @@ describe.each([
if (useOwnedStateEvents) {
room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
}
const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent);
const restartScheduledDelayedEvent = waitForMockCall(client._unstable_restartScheduledDelayedEvent);
const sentDelayedState = waitForMockCall(
client._unstable_sendDelayedStateEvent,
Promise.resolve({
@@ -188,7 +206,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,15 +230,15 @@ describe.each([
return Promise.reject(error);
});
});
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{
delayedLeaveEventDelayMs: 9000,
},
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
manager.join([focus]);
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
await sendDelayedStateAttempt;
@@ -246,6 +264,7 @@ describe.each([
expires: 14400000,
device_id: "AAAAAAA",
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
} satisfies SessionMembershipData,
userStateKey,
@@ -253,13 +272,13 @@ describe.each([
await sentDelayedState;
// should have prepared the heartbeat to keep delaying the leave event while still connected
await updatedDelayedEvent;
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
await restartScheduledDelayedEvent;
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
await jest.advanceTimersByTimeAsync(5000);
// should update delayed disconnect
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
}
it("sends a membership event after rate limits during delayed event setup when joining a call", async () => {
@@ -275,8 +294,8 @@ 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);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
@@ -287,39 +306,74 @@ 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);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new TestMembershipManager(
{ delayedLeaveEventDelayMs: 123456 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive);
const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession);
manager.join([focus]);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 123456 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
"_@alice:example.org_AAAAAAA_m.call",
);
});
});
it("rejoins if delayed event is not found (404)", async () => {
const RESTART_DELAY = 15000;
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: RESTART_DELAY },
room,
client,
callSession,
);
// Join with the membership manager
manager.join([focus]);
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_restartScheduledDelayedEvent 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 TestMembershipManager(
const manager = new MembershipManager(
{ membershipEventExpiryMs: 1234567 },
room,
client,
() => undefined,
callSession,
);
manager.join([focus], focusActive);
manager.join([focus]);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
@@ -331,21 +385,22 @@ describe.each([
device_id: "AAAAAAA",
expires: 1234567,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: {
focus_selection: "oldest_membership",
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);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
manager.join([focus], focusActive);
manager.join([focus]);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
@@ -353,103 +408,67 @@ 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);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
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);
manager.join([focus], focusActive);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave();
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
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, callSession);
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
// After joining we want our own focus to be the one we select.
getOldestMembership.mockReturnValue(
mockCallMembership(
{
...membershipTemplate,
foci_preferred: [
{
livekit_alias: "!active:active.url",
livekit_service_url: "https://active.url",
type: "livekit",
},
],
device_id: client.getDeviceId(),
created_ts: 1000,
},
room.roomId,
client.getUserId()!,
),
);
expect(manager.getActiveFocus()).toStrictEqual(focus);
getOldestMembership.mockReturnValue(
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
room.roomId,
),
);
// If there is an older member we use its focus.
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
});
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_cancelScheduledDelayedEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
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);
@@ -457,14 +476,17 @@ describe.each([
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled();
expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, 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_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
// Our own membership is removed:
@@ -473,18 +495,44 @@ describe.each([
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled();
});
it("updates the UpdateExpiry entry in the action scheduler", async () => {
const manager = new MembershipManager({}, room, client, 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_restartScheduledDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
(client._unstable_restartScheduledDelayedEvent 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_restartScheduledDelayedEvent).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 `delayedLeaveEventRestartMs`", async () => {
const manager = new TestMembershipManager(
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);
@@ -492,30 +540,31 @@ describe.each([
// The first call is from checking id the server deleted the delayed event
// so it does not need a `advanceTimersByTime`
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
for (let i = 2; i <= 12; i++) {
// flush promises before advancing the timers to make sure schedulers are setup
await jest.advanceTimersByTimeAsync(10_000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(i);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
// expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
}
});
// !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(
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 +578,24 @@ 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, 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, callSession);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -558,8 +608,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, callSession);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -575,7 +625,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, callSession);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -592,9 +642,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 +652,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
@@ -620,11 +669,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, callSession);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -649,8 +697,8 @@ describe.each([
});
});
describe("retries sending update delayed leave event restart", () => {
it("resends the initial check delayed update event !FailsForLegacy", async () => {
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
it("resends the initial check delayed update event", async () => {
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -659,29 +707,29 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive);
// Hit rate limit
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
// Hit second rate limit.
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2);
// Setup resolve
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue(undefined);
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
});
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 +740,7 @@ describe.each([
new Headers({ "Retry-After": "2" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -700,10 +748,10 @@ 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(
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -712,7 +760,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -720,23 +768,23 @@ 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, 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(
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 +797,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, callSession);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -762,13 +810,170 @@ describe.each([
expect(client.sendStateEvent).toHaveBeenCalled();
});
});
describe("probablyLeft", () => {
it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => {
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 10000 },
room,
client,
callSession,
);
const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers<EmptyObject>();
const probablyLeftEmit = jest.fn();
manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit);
manager.join([focus], focusActive);
try {
// Let the scheduler run one iteration so that we can send the join state event
await waitForMockCall(client._unstable_restartScheduledDelayedEvent);
// We never resolve the delayed event so that we can test the probablyLeft event.
// This simulates the case where the server does not respond to the delayed event.
client._unstable_restartScheduledDelayedEvent = jest.fn(() => stuckPromise);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000.
// We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding.
// The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test.
await jest.advanceTimersByTimeAsync(5000);
// No emission after 5s
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(4999);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3);
expect(probablyLeftEmit).not.toHaveBeenCalledWith(true);
// Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms.
(client._unstable_restartScheduledDelayedEvent as Mock<any>).mockResolvedValue({});
// Emit after 10s
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(4);
expect(probablyLeftEmit).toHaveBeenCalledWith(true);
// Mock a sync which does not include our own membership
await manager.onRTCSessionMemberUpdate([]);
// Wait for the current ongoing delayed event sending to finish
await jest.advanceTimersByTimeAsync(1);
// We should send a new state event and an associated delayed leave event.
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
// At the same time we expect the probablyLeft event to be emitted with false so we are back operational.
expect(probablyLeftEmit).toHaveBeenCalledWith(false);
} finally {
rejectStuckPromise();
}
});
});
describe("updateCallIntent()", () => {
it("should fail if the user has not joined the call", async () => {
const manager = new MembershipManager({}, room, client, callSession);
// After joining we want our own focus to be the one we select.
try {
await manager.updateCallIntent("video");
throw Error("Should have thrown");
} catch {}
});
it("can adjust the intent", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
expect(eventContent["created_ts"]).toEqual(membership.createdTs());
expect(eventContent["m.call.intent"]).toEqual("video");
});
it("does nothing if the intent doesn't change", async () => {
const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(0);
});
});
describe("StickyEventMembershipManager", () => {
beforeEach(() => {
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendStickyDelayedEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_sendStickyEvent as Mock<any>).mockResolvedValue(undefined);
});
describe("join()", () => {
describe("sends an rtc membership event", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent as Mock,
);
const memberManager = new StickyEventMembershipManager(
undefined,
room,
client,
callSession,
"@alice:example.org:AAAAAAA_m.call",
);
memberManager.join([], focus);
await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" }));
// Test we sent the initial join
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
room.roomId,
3600000,
null,
"org.matrix.msc4143.rtc.member",
{
application: { type: "m.call" },
member: {
user_id: "@alice:example.org",
id: "@alice:example.org:AAAAAAA_m.call",
device_id: "AAAAAAA",
},
slot_id: "m.call#",
rtc_transports: [focus],
versions: [],
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
);
restartScheduledDelayedEventHandle.resolve?.();
// Ensure we have sent the delayed disconnect event.
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
room.roomId,
MAX_STICKY_DURATION_MS,
{ delay: 8000 },
null,
"org.matrix.msc4143.rtc.member",
{
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
);
// ..once
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1);
});
});
});
});
});
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom(membershipTemplate);
const room = makeMockRoom([membershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, () => undefined, logger);
const membershipManager = new MembershipManager(undefined, room, client, callSession);
const spy = jest.spyOn(console, "error");
// Double join
@@ -777,5 +982,5 @@ it("Should prefix log with MembershipManager used", () => {
expect(spy).toHaveBeenCalled();
const logline: string = spy.mock.calls[0][0];
expect(logline.startsWith("[NewMembershipManager]")).toBe(true);
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.membership, aKey)).toBe(false);
// Then we receive the most recent key out of order
const isOutdated = filter.isOutdated(aKey.membership, 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,
membership: { userId: "@alice:localhost", deviceId: "ABDE", memberId: "@alice:localhost:ABCDE" },
key: new Uint8Array(16),
};
}
});
File diff suppressed because it is too large Load Diff
@@ -1,179 +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.
*/
import { type Mocked } from "jest-mock";
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } 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 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, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
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, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
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 = mockCallMembership(membershipTemplate, roomId, "@alice:example.org");
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);
});
});
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock";
import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
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";
@@ -50,9 +50,15 @@ describe("ToDeviceKeyTransport", () => {
},
};
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
transport = new ToDeviceKeyTransport(
{ userId: "@alice:example.org", deviceId: "MYDEVICE", memberId: "@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 () => {
@@ -61,21 +67,9 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
roomId,
"@bob:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
roomId,
"@carl:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
roomId,
"@mat:example.org",
),
{ 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);
@@ -93,6 +87,7 @@ describe("ToDeviceKeyTransport", () => {
},
member: {
claimed_device_id: "MYDEVICE",
id: "@alice:example.org:MYDEVICE",
},
room_id: roomId,
session: {
@@ -100,6 +95,7 @@ describe("ToDeviceKeyTransport", () => {
call_id: "",
scope: "m.room",
},
sent_ts: expect.any(Number),
},
);
@@ -113,8 +109,13 @@ describe("ToDeviceKeyTransport", () => {
keyBase64Encoded: string;
index: number;
}>();
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
receivedKeyResolvers.resolve({ userId, deviceId, keyBase64Encoded, index });
transport.on(KeyTransportEvents.ReceivedKeys, (membership, keyBase64Encoded, index, _timestamp) => {
receivedKeyResolvers.resolve({
userId: membership.userId,
deviceId: membership.deviceId,
keyBase64Encoded,
index,
});
});
transport.start();
@@ -153,11 +154,7 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
roomId,
"@alice:example.org",
),
{ userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
]);
transport.start();
@@ -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;
+69 -17
View File
@@ -15,16 +15,26 @@ limitations under the License.
*/
import { EventEmitter } from "stream";
import { type Mocked } from "jest-mock";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
import { secureRandomString } from "../../../src/randomstring";
import { type CallMembershipIdentityParts } from "src/matrixrtc/EncryptionManager";
import { logger } from "../../../src/logger.ts";
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
export const membershipTemplate: SessionMembershipData = {
export const owmMemberIdentity: CallMembershipIdentityParts = {
deviceId: "AAAAAAA",
memberId: "@alice:example.org:AAAAAAA",
userId: "@alice:example.org",
};
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" },
@@ -50,6 +60,11 @@ export type MockClient = Pick<
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "_unstable_cancelScheduledDelayedEvent"
| "_unstable_restartScheduledDelayedEvent"
| "_unstable_sendScheduledDelayedEvent"
| "_unstable_sendStickyEvent"
| "_unstable_sendStickyDelayedEvent"
| "cancelPendingEvent"
>;
/**
@@ -63,16 +78,23 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
sendStateEvent: jest.fn(),
cancelPendingEvent: jest.fn(),
_unstable_updateDelayedEvent: jest.fn(),
_unstable_cancelScheduledDelayedEvent: jest.fn(),
_unstable_restartScheduledDelayedEvent: jest.fn(),
_unstable_sendScheduledDelayedEvent: jest.fn(),
_unstable_sendDelayedStateEvent: jest.fn(),
_unstable_sendStickyEvent: jest.fn(),
_unstable_sendStickyDelayedEvent: jest.fn(),
};
}
export function makeMockRoom(
membershipData: MembershipData,
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
membershipData: MembershipData[],
useStickyEvents = false,
): Mocked<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 roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId);
const ts = Date.now();
const room = Object.assign(new EventEmitter(), {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
@@ -80,17 +102,20 @@ export function makeMockRoom(
getState: jest.fn().mockReturnValue(roomState),
}),
getVersion: jest.fn().mockReturnValue("default"),
}) as unknown as Room;
_unstable_getStickyEvents: jest
.fn()
.mockImplementation(() =>
useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [],
) as any,
});
return Object.assign(room, {
emitTimelineEvent: (event: MatrixEvent) =>
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
});
}) as unknown as Mocked<Room & { emitTimelineEvent: (event: MatrixEvent) => void }>;
}
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}`];
@@ -120,12 +145,17 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
};
}
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,
stateKey?: string,
): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(type),
@@ -134,17 +164,39 @@ export function makeMockEvent(
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
getRoomId: jest.fn().mockReturnValue(roomId),
getId: jest.fn().mockReturnValue(secureRandomString(8)),
getStateKey: jest.fn().mockReturnValue(stateKey),
isDecryptionFailure: jest.fn().mockReturnValue(false),
} as unknown as MatrixEvent;
}
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
const sender = customSender ?? "@mock:user.example";
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
export function mockRTCEvent(
{ user_id: sender, ...membershipData }: MembershipData,
roomId: string,
stickyDuration?: number,
timestamp?: number,
): MatrixEvent {
return {
...makeMockEvent(
stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix,
sender,
roomId,
membershipData,
timestamp,
!stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "",
),
unstableStickyExpiresAt: stickyDuration,
} as unknown as MatrixEvent;
}
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
export function mockCallMembership(
membershipData: MembershipData,
roomId: string,
rtcBackendIdentity?: string,
): CallMembership {
const ev = mockRTCEvent(membershipData, roomId);
(ev.getContent as jest.Mock).mockReturnValue(membershipData);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
}
export function makeKey(id: number, key: string): { key: string; index: number } {
+135
View File
@@ -0,0 +1,135 @@
/*
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 CallMembership } from "../../../src/matrixrtc";
import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types";
describe("types", () => {
describe("isMyMembership", () => {
it("returns false if userId is different", () => {
expect(
isMyMembership(
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
"@bob:example.org",
"DEVICE",
),
).toBe(false);
});
it("returns true if userId and device is the same", () => {
expect(
isMyMembership(
{ sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership,
"@alice:example.org",
"DEVICE",
),
).toBe(true);
});
});
});
describe("IRTCNotificationContent", () => {
const validBase = Object.freeze({
"m.mentions": { user_ids: [], room: true },
"notification_type": "notification",
"sender_ts": 123,
"lifetime": 1000,
});
it("parses valid content", () => {
const res = parseCallNotificationContent({ ...validBase });
expect(res).toMatchObject(validBase);
});
it("caps lifetime to 90000ms", () => {
const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 });
expect(res.lifetime).toBe(90000);
});
it("throws on malformed m.mentions", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
"m.mentions": "not an object",
} as any),
).toThrow("malformed m.mentions");
});
it("throws on missing or invalid notification_type", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
notification_type: undefined,
} as any),
).toThrow("Missing or invalid notification_type");
expect(() =>
parseCallNotificationContent({
...validBase,
notification_type: 123 as any,
} as any),
).toThrow("Missing or invalid notification_type");
});
it("throws on missing or invalid sender_ts", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
sender_ts: undefined,
} as any),
).toThrow("Missing or invalid sender_ts");
expect(() =>
parseCallNotificationContent({
...validBase,
sender_ts: "123" as any,
} as any),
).toThrow("Missing or invalid sender_ts");
});
it("throws on missing or invalid lifetime", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
lifetime: undefined,
} as any),
).toThrow("Missing or invalid lifetime");
expect(() =>
parseCallNotificationContent({
...validBase,
lifetime: "1000" as any,
} as any),
).toThrow("Missing or invalid lifetime");
});
it("accepts valid relation (m.reference)", () => {
// Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`.
const res = parseCallNotificationContent({
...validBase,
relation: { rel_type: "m.reference", event_id: "$ev" },
} as any);
expect(res).toBeTruthy();
});
it("throws on invalid relation rel_type", () => {
expect(() =>
parseCallNotificationContent({
...validBase,
relation: { rel_type: "m.annotation", event_id: "$ev" },
} as any),
).toThrow("Invalid relation");
});
});
+69 -4
View File
@@ -16,10 +16,11 @@ limitations under the License.
import { type MockedObject } from "jest-mock";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import {
type IAnnotatedPushRule,
type IStickyEvent,
type MatrixClient,
PushRuleActionName,
Room,
@@ -335,6 +336,31 @@ describe("MatrixEvent", () => {
}
});
describe("state key packing", () => {
it("should pack the state key during encryption", () => {
const ev = createStateEvent("$event1:server", "m.room.topic", "", { topic: "" });
expect(ev.getStateKey()).toStrictEqual("");
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(ev.getStateKey()).toStrictEqual("");
expect(ev.getWireStateKey()).toStrictEqual("m.room.topic:");
const keyedEv = createStateEvent("$event2:server", "m.beacon_info", "@alice:server", {});
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
keyedEv.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(keyedEv.getStateKey()).toStrictEqual("@alice:server");
expect(keyedEv.getWireStateKey()).toStrictEqual("m.beacon_info:@alice:server");
});
function createStateEvent(eventId: string, type: string, stateKey: string, content?: IContent): MatrixEvent {
return new MatrixEvent({
type,
state_key: stateKey,
content,
event_id: eventId,
});
}
});
describe("applyVisibilityEvent", () => {
it("should emit VisibilityChange if a change was made", async () => {
const ev = new MatrixEvent({
@@ -379,7 +405,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 +431,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 +457,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. **",
@@ -567,6 +599,39 @@ describe("MatrixEvent", () => {
expect(stateEvent.isState()).toBeTruthy();
expect(stateEvent.threadRootId).toBeUndefined();
});
it("should calculate sticky duration correctly", async () => {
const evData: IStickyEvent = {
event_id: "$event_id",
type: "some_state_event",
content: {},
sender: "@alice:example.org",
origin_server_ts: 50,
msc4354_sticky: {
duration_ms: 1000,
},
unsigned: {
msc4354_sticky_duration_ttl_ms: 5000,
},
};
try {
jest.useFakeTimers();
jest.setSystemTime(50);
// Prefer unsigned
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
// Fall back to `duration_ms`
expect(
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
).toEqual(1050);
// Prefer current time if `origin_server_ts` is more recent.
expect(
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
.unstableStickyExpiresAt,
).toEqual(1050);
} finally {
jest.useRealTimers();
}
});
});
function mainTimelineLiveEventIds(room: Room): Array<string> {
+4 -2
View File
@@ -127,15 +127,17 @@ describe("Poll", () => {
it("waits for existing relations request to finish when getting responses", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
// @ts-expect-error TS2769
const spy = jest.spyOn(poll, "fetchResponses");
const firstResponsePromise = poll.getResponses();
const secondResponsePromise = poll.getResponses();
await firstResponsePromise;
expect(firstResponsePromise).toEqual(secondResponsePromise);
await secondResponsePromise;
expect(spy).toHaveBeenCalledTimes(1);
expect(mockClient.relations).toHaveBeenCalledTimes(1);
});
it("filters relations for relevent response events", async () => {
it("filters relations for relevant response events", async () => {
const replyEvent = makeRelatedEvent({ type: "m.room.message" });
const stableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.stable! });
const unstableResponseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.unstable });
+422
View File
@@ -0,0 +1,422 @@
import { type IStickyEvent, MatrixEvent } from "../../../src";
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";
describe("RoomStickyEvents", () => {
let stickyEvents: RoomStickyEventsStore;
const emitSpy: jest.Mock = jest.fn();
const stickyEvent: IStickyEvent = {
event_id: "$foo:bar",
room_id: "!roomId",
type: "org.example.any_type",
msc4354_sticky: {
duration_ms: 15000,
},
content: {
msc4354_sticky_key: "foobar",
},
sender: "@alice:example.org",
origin_server_ts: Date.now(),
unsigned: {},
};
beforeEach(() => {
emitSpy.mockReset();
stickyEvents = new RoomStickyEventsStore();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
});
afterEach(() => {
stickyEvents?.clear();
});
describe("addStickyEvents", () => {
it("should allow adding an event without a msc4354_sticky_key", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
});
it("should not allow adding an event without a msc4354_sticky property", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event without a sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event with an invalid sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should ignore old events", () => {
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should be able to just add an event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace events on ID tie break", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
event_id: "$abc:bar",
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace a newer event with an older event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
origin_server_ts: 1,
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should replace an older event with a newer event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$new",
origin_server_ts: Date.now() + 2000,
});
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([newerEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
});
it("should allow multiple events with the same sticky key for different event types", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
const anotherEv = new MatrixEvent({
...stickyEvent,
type: "org.example.another_type",
});
stickyEvents.addStickyEvents([originalEv, anotherEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
});
it("should emit when a new sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
it("should emit when a new unkeyed sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
});
describe("getStickyEvents", () => {
it("should have zero sticky events", () => {
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should contain a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
});
it("should contain two sticky events", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
const ev2 = new MatrixEvent({
...stickyEvent,
sender: "@fibble:bobble",
content: {
msc4354_sticky_key: "bibble",
},
});
stickyEvents.addStickyEvents([ev, ev2]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
});
});
describe("getKeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toBeUndefined();
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toEqual(ev);
});
});
describe("getUnkeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
content: {
msc4354_sticky_key: undefined,
},
});
stickyEvents.addStickyEvents([ev]);
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
});
});
describe("cleanExpiredStickyEvents", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should emit when a sticky event expires", () => {
jest.setSystemTime(1000);
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: 0,
});
const evLater = new MatrixEvent({
...stickyEvent,
event_id: "$baz:bar",
sender: "@bob:example.org",
origin_server_ts: 1000,
});
stickyEvents.addStickyEvents([ev, evLater]);
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
// Then expire the next event
jest.advanceTimersByTime(1000);
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
});
it("should emit two events when both expire at the same time", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev1 = new MatrixEvent({
...stickyEvent,
event_id: "$eventA",
origin_server_ts: 0,
});
const ev2 = new MatrixEvent({
...stickyEvent,
event_id: "$eventB",
content: {
msc4354_sticky_key: "key_2",
},
origin_server_ts: 0,
});
stickyEvents.addStickyEvents([ev1, ev2]);
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
});
it("should emit when a unkeyed sticky event expires", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
});
describe("handleRedaction", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should not emit if the event does not exist in the map", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction("$123456");
expect(emitSpy).not.toHaveBeenCalled();
});
it("should emit a remove when the event exists in the map without a predecessor", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(stickyEvent.event_id);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
it("should emit a remove when the event has no sticky key", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(stickyEvent.event_id);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
it("should emit an update when the event exists in the map with a predecessor", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
stickyEvents.addStickyEvents([ev, newerEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(newerEv.getId()!);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
});
it("should emit a remove if the previous event has expired", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
stickyEvents.addStickyEvents([ev, newerEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
// Expire the older event.
jest.advanceTimersByTime(stickyEvent.msc4354_sticky.duration_ms);
// Redact the newer event
stickyEvents.handleRedaction(newerEv.getId()!);
expect(emitSpy).toHaveBeenCalledWith([], [], [newerEv]);
});
it("should recurse the chain of events if the previous event has been redacted", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const middleEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
jest.advanceTimersByTime(1000);
const newestEv = new MatrixEvent({
...stickyEvent,
event_id: "$newest-ev",
origin_server_ts: Date.now() + 2000,
});
stickyEvents.addStickyEvents([ev, middleEv, newestEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
// Mark the middle event as redacted.
middleEv.setUnsigned({
redacted_because: {
event_id: "$foo",
} as any,
});
// Redact the newer event
stickyEvents.handleRedaction(newestEv.getId()!);
// expect immediate transition from newestEv -> ev and skipping middleEv
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
});
it("should revert to the most recent valid event regardless of insertion order", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const middleEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
jest.advanceTimersByTime(1000);
const newestEv = new MatrixEvent({
...stickyEvent,
event_id: "$newest-ev",
origin_server_ts: Date.now() + 2000,
});
// Invert in reverse order, to make sure we retain the older events.
stickyEvents.addStickyEvents([newestEv, middleEv, ev]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
// Mark the middle event as redacted.
middleEv.setUnsigned({
redacted_because: {
event_id: "$foo",
} as any,
});
// Redact the newer event
stickyEvents.handleRedaction(newestEv.getId()!);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []);
});
it("should handle redaction when using `handleRedaction` with a `MatrixEvent` parameter", () => {
const emitSpy = jest.fn();
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: Date.now(),
});
jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event.
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$newer-ev",
origin_server_ts: Date.now() + 1000,
});
stickyEvents.addStickyEvents([ev, newerEv]);
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
stickyEvents.handleRedaction(newerEv);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []);
});
});
});
+255
View File
@@ -0,0 +1,255 @@
/*
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, EventType, 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);
});
});
it("should ignore invalid m.room.name events", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const invalidNameEvent = new MatrixEvent({
type: EventType.RoomName,
content: {
name: { invalid: 123 },
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([invalidNameEvent]);
room.recalculate();
expect(room.name).toEqual("Empty room");
});
describe("getAltAliases()", () => {
it("should ignore invalid events", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const invalidAliasEvent = new MatrixEvent({
type: EventType.RoomCanonicalAlias,
content: {
alt_aliases: [123, "#foo:bar"],
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([invalidAliasEvent]);
room.recalculate();
expect(room.getAltAliases()).toEqual(["#foo:bar"]);
});
});
describe("calculateRoomName()", () => {
it("should ignore empty m.room.name 'name' field", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const event = new MatrixEvent({
type: EventType.RoomName,
content: {
name: "",
},
state_key: "",
event_id: "$123",
room_id: room.roomId,
sender: CREATOR_USER_ID,
});
// Set up the room
room.currentState.setStateEvents([event]);
room.recalculate();
expect(room.name).not.toEqual("");
});
});
});
+210 -1
View File
@@ -22,7 +22,7 @@ import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../..
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
import { Direction, EventStatus, EventType, MatrixEvent, RelationType } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
@@ -773,6 +773,215 @@ describe("Thread", () => {
expect(lastEvent).toBe(message1);
expect(firstEvent).toBe(message2);
});
it("Edit events are properly aggregated in threads with server-side support", async () => {
// This test reproduces the race condition bug from https://github.com/element-hq/element-web/issues/30617
// The bug occurs when edits arrive while the thread is not initialized,
// causing aggregation to fail because the target event isn't in the timeline yet
// Given a thread exists with server-side support enabled
const myUserId = "@alice:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("!room:z", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
// Create a root event
const rootEvent = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Root message",
event: true,
});
// Create thread manually - starts with initialEventsFetched = false
const thread = new Thread(rootEvent.getId()!, rootEvent, {
room: room,
client: client,
pendingEventOrdering: PendingEventOrdering.Detached,
});
// The thread is NOT initialized - this is the key to reproducing the bug!
expect(thread.initialEventsFetched).toBe(false);
// Create a message that will be edited
const originalMessage = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Original message in thread",
relatesTo: {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": thread.id,
"m.in_reply_to": {
event_id: thread.id,
},
},
event: true,
});
// Create edit events BEFORE the original message is in the timeline
const edit1 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 1");
const edit2 = mkEdit(originalMessage, client, myUserId, room.roomId, "Edit 2");
const edit3 = mkEdit(originalMessage, client, myUserId, room.roomId, "Final edit");
// CRITICAL: Add edits while thread is NOT initialized
// They will be queued in replayEvents and aggregation will be attempted but fail
await thread.addEvent(edit1, false);
// Check the aggregation state after adding first edit
// With our fix: edits should NOT be aggregated yet (thread not initialized)
// Without fix: edits would be aggregated but fail to link to target
const relationsAfterFirstEdit = thread.timelineSet.relations?.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Replace,
EventType.RoomMessage,
);
// With the fix, no aggregation happens yet (which is correct)
// Without the fix, aggregation would happen but fail silently
expect(relationsAfterFirstEdit).toBeUndefined();
// Add remaining edits
await thread.addEvent(edit2, false);
await thread.addEvent(edit3, false);
// Check that edits went to replayEvents
expect(thread.replayEvents).toHaveLength(3);
// Now initialize the thread and add the original message
thread.initialEventsFetched = true;
// Clear replayEvents and add the original message
const replayEvents = [...(thread.replayEvents || [])];
thread.replayEvents = [];
// Add original message first
await thread.addEvent(originalMessage, false);
// At this point, the original message should NOT have the edits aggregated yet
// because they were attempted when the target wasn't in timeline
const replacingEventBeforeReplay = originalMessage.replacingEvent();
// With the fix, edits should not be aggregated yet (pre-init)
expect(replacingEventBeforeReplay).toBeNull();
// Then replay the edits
for (const event of replayEvents) {
await thread.addEvent(event, false);
}
// After replay, check aggregation
const replacingEvent = originalMessage.replacingEvent();
// This should now work because edits were re-aggregated when replayed
expect(replacingEvent).toBe(edit3);
// The content should also be updated
expect(originalMessage.getContent().body).toBe("Final edit");
// Relations for replaces should now exist and include all edits in order
const replaceRels = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Replace,
EventType.RoomMessage,
)!;
const replaceIds = replaceRels.getRelations().map((e) => e.getId());
expect(replaceIds).toHaveLength(3);
expect(replaceIds[0]).toBe(edit1.getId());
expect(replaceIds[1]).toBe(edit2.getId());
expect(replaceIds[2]).toBe(edit3.getId());
});
it("Reactions aggregate pre-init and remain idempotent on replay", async () => {
const myUserId = "@alice:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
// Force server-side support so threads start uninitialised
const prevSupport = Thread.hasServerSideSupport;
Thread.setServerSideSupport(FeatureSupport.Stable);
try {
const room = new Room("!room:z", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
// Create a root event and thread
const rootEvent = mkMessage({ room: room.roomId, user: myUserId, msg: "Root", event: true });
const thread = new Thread(rootEvent.getId()!, rootEvent, {
room,
client,
pendingEventOrdering: PendingEventOrdering.Detached,
});
expect(thread.initialEventsFetched).toBe(false);
// A message inside the thread to react to
const originalMessage = mkMessage({
room: room.roomId,
user: myUserId,
msg: "Thread message",
relatesTo: {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": thread.id,
"m.in_reply_to": { event_id: thread.id },
},
event: true,
});
// Create 2 reactions before the message is in the timeline (pre-init)
const reaction1 = mkReaction(originalMessage, client, myUserId, room.roomId);
const reaction2 = mkReaction(originalMessage, client, myUserId, room.roomId);
// Add reactions while thread is NOT initialised
thread.addEvent(reaction1, false);
thread.addEvent(reaction2, false);
// Relations should already include the reactions pre-init
const relsBefore = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
)!;
expect(relsBefore).toBeTruthy();
const beforeIds = new Set(relsBefore.getRelations().map((e) => e.getId()));
expect(beforeIds.size).toBe(2);
// Now initialise and replay
// Ensure reactions are queued for replay as well
expect(thread.replayEvents).toHaveLength(2);
const replay = [...(thread.replayEvents || [])];
thread.replayEvents = [];
thread.initialEventsFetched = true;
// Add the original message first so it becomes findable
thread.addEvent(originalMessage, false);
// Replay reactions
for (const ev of replay) thread.addEvent(ev, false);
// Ensure no duplicates after replay (idempotent aggregation)
const relsAfter = thread.timelineSet.relations!.getChildEventsForEvent(
originalMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
)!;
const afterIds = new Set(relsAfter.getRelations().map((e) => e.getId()));
expect(afterIds.size).toBe(2);
expect(afterIds).toEqual(beforeIds);
} finally {
// restore
Thread.setServerSideSupport(prevSupport);
}
});
});
});
});
+17
View File
@@ -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", () => {
+37 -41
View File
@@ -21,7 +21,6 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
import { logger } from "../../../src/logger";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("OidcTokenRefresher", () => {
@@ -78,51 +77,49 @@ describe("OidcTokenRefresher", () => {
fetchMock.resetBehavior();
});
it("throws when oidc client cannot be initialised", async () => {
jest.spyOn(logger, "error");
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.oidcClientReady).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OIDC client.",
// error from OidcClient
expect.any(Error),
);
});
it("initialises oidc client", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// @ts-ignore peek at private property to see we initialised the client correctly
expect(refresher.oidcClient.settings).toEqual(
expect.objectContaining({
client_id: clientId,
redirect_uri: redirectUri,
authority: authConfig.issuer,
scope,
}),
);
});
describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => {
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
"Cannot get new token before OIDC client is initialised.",
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
});
it("should retry initialisation", async () => {
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
// put the successful mock back
fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config, { overwriteRoutes: true });
const result = await refresher.doRefreshAccessToken("token");
expect(result).toEqual(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
const result = await refresher.doRefreshAccessToken("refresh-token");
@@ -140,13 +137,12 @@ describe("OidcTokenRefresher", () => {
it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// spy on our stub
jest.spyOn(refresher, "persistTokens");
jest.spyOn(refresher as any, "persistTokens");
await refresher.doRefreshAccessToken("refresh-token");
expect(refresher.persistTokens).toHaveBeenCalledWith(
expect((refresher as any).persistTokens).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
+4 -3
View File
@@ -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.
+8 -4
View File
@@ -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
View File
@@ -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();
});
});
+194 -7
View File
@@ -20,6 +20,7 @@ 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";
@@ -38,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,
@@ -66,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",
@@ -75,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 () {
@@ -259,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,
@@ -273,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 () {
@@ -310,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,
@@ -851,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", () => {
+4 -4
View File
@@ -19,7 +19,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, type Optional, PollStartEvent } from "matrix-events-sdk";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise, type IMessageOpts } from "../test-utils/test-utils";
@@ -197,8 +197,8 @@ describe("Room", function () {
const addRoomThreads = (
room: Room,
thread1EventTs: Optional<number>,
thread2EventTs: Optional<number>,
thread1EventTs?: number,
thread2EventTs?: number,
): { thread1?: Thread; thread2?: Thread } => {
const result: { thread1?: Thread; thread2?: Thread } = {};
@@ -4159,7 +4159,7 @@ describe("Room", function () {
});
it("when there is only one thread, it should return this one", () => {
const { thread1 } = addRoomThreads(room, 23, null);
const { thread1 } = addRoomThreads(room, 23);
expect(room.getLastThread()).toBe(thread1);
});
@@ -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,6 +40,7 @@ import {
type UIAuthCallback,
} from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { logger } from "../../../src/logger.ts";
describe("OutgoingRequestProcessor", () => {
/** the OutgoingRequestProcessor implementation under test */
@@ -76,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 */
@@ -293,7 +294,7 @@ describe("OutgoingRequestProcessor", () => {
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
@@ -325,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
onlyData: true,
});
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
});
afterEach(() => {
@@ -92,9 +92,11 @@ describe("OutgoingRequestsManager", () => {
await secondRequest;
await thirdRequest;
// outgoingRequests should be called twice in total, as the second and third requests are
// processed in the same loop.
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
// outgoingRequests should be called three times in total:
// 1. the first time,
// 2. the second and third requests processed in the same loop, and
// 3. checking that all requests are finished
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked, type SpyInstance } from "jest-mock";
import { type Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "fetch-mock-jest";
@@ -211,14 +211,14 @@ describe("PerSessionKeyBackupDownloader", () => {
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA1`, mockCipherKey);
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const expectImported = expectSessionImported("!roomA", "sessionA1");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveLastReturnedWith(Promise.resolve({ ok: false, error: "MISSING_DECRYPTION_KEY" }));
await expect(spy.mock.results[0].value).rejects.toThrow("MISSING_DECRYPTION_KEY");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
await jest.runAllTimersAsync();
@@ -237,7 +237,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const spy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
@@ -297,7 +297,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
describe("Given no usable backup available", () => {
let getConfigSpy: SpyInstance;
let getConfigSpy: jest.SpyInstance;
beforeEach(async () => {
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
@@ -318,7 +318,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -336,7 +336,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -355,7 +355,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -377,7 +377,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -399,7 +399,7 @@ describe("PerSessionKeyBackupDownloader", () => {
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
await expect(getConfigSpy.mock.results[0].value).resolves.toEqual(null);
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
@@ -488,7 +488,7 @@ describe("PerSessionKeyBackupDownloader", () => {
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const rateDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
@@ -542,7 +542,7 @@ describe("PerSessionKeyBackupDownloader", () => {
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const errorDeferred = Promise.withResolvers<void>();
keyQuerySpy.mockImplementation(
@@ -606,7 +606,7 @@ describe("PerSessionKeyBackupDownloader", () => {
});
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const keyQuerySpy: jest.SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
@@ -36,6 +36,7 @@ import {
AllDevicesIsolationMode,
OnlySignedDevicesIsolationMode,
} from "../../../src/crypto-api";
import { logger } from "../../../src/logger.ts";
describe("RoomEncryptor", () => {
describe("History Visibility", () => {
@@ -71,6 +72,7 @@ describe("RoomEncryptor", () => {
body: text,
msgtype: "m.text",
}),
isState: () => false,
makeEncrypted: jest.fn().mockReturnValue(undefined),
} as unknown as Mocked<MatrixEvent>;
}
@@ -108,6 +110,7 @@ describe("RoomEncryptor", () => {
} as unknown as Mocked<Room>;
roomEncryptor = new RoomEncryptor(
logger,
mockOlmMachine,
mockKeyClaimManager,
mockOutgoingRequestManager,
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RustCrypto importing and exporting room keys should import and export keys 1`] = `
{
+2 -1
View File
@@ -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);
});
+266 -48
View File
@@ -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,7 +70,6 @@ import {
import * as testData from "../../test-utils/test-data";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
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";
@@ -117,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"],
@@ -128,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 () => {
@@ -140,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"],
@@ -151,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 () => {
@@ -162,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"],
@@ -174,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 () => {
@@ -186,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,
@@ -251,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(),
@@ -273,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");
@@ -289,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];
@@ -311,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
@@ -358,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,
@@ -400,6 +408,7 @@ describe("initRustCrypto", () => {
);
const PICKLE_KEY = "pickle1234";
const logger = new DebugLogger(debug("matrix-js-sdk:test:initRustCrypto"));
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
@@ -418,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];
@@ -551,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[] = [
@@ -576,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 () => {
@@ -813,13 +823,14 @@ 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,
TEST_DEVICE_ID,
secretStorage,
{} as CryptoCallbacks,
false,
);
async function createSecretStorageKey() {
@@ -843,9 +854,27 @@ describe("RustCrypto", () => {
});
});
it("getSecretStorageStatus", async () => {
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue("blah"),
isStored: jest.fn().mockResolvedValue({ blah: {} }),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
await expect(rustCrypto.getSecretStorageStatus()).resolves.toEqual({
defaultKeyId: "blah",
ready: true,
secretStorageKeyValidityMap: {
"m.cross_signing.master": true,
"m.cross_signing.self_signing": true,
"m.cross_signing.user_signing": true,
},
});
});
it("isSecretStorageReady", async () => {
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue(null),
isStored: jest.fn().mockResolvedValue(null),
} as unknown as Mocked<ServerSideSecretStorage>;
const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage);
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
@@ -898,6 +927,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(
@@ -957,7 +987,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
@@ -1015,7 +1049,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,
@@ -1102,6 +1136,7 @@ describe("RustCrypto", () => {
it.each([
[undefined, undefined, null],
["Other", -1, EventShieldReason.UNKNOWN],
[
"Encrypted by an unverified user.",
RustSdkCryptoJs.ShieldStateCode.UnverifiedIdentity,
@@ -1128,6 +1163,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(() => {});
@@ -1228,7 +1268,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,
@@ -1471,7 +1511,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,
@@ -1537,14 +1577,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",
@@ -1558,7 +1590,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,
@@ -1630,7 +1662,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,
@@ -1782,14 +1814,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
@@ -1923,14 +1947,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
@@ -2291,8 +2307,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);
@@ -2348,6 +2365,207 @@ describe("RustCrypto", () => {
expect(dehydratedDeviceIsDeleted).toBeTruthy();
});
});
describe("maybeAcceptKeyBundle", () => {
let mockOlmMachine: Mocked<OlmMachine>;
let rustCrypto: RustCrypto;
beforeEach(async () => {
mockOlmMachine = {
queryKeysForUsers: jest.fn().mockReturnValue({}),
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.queryKeysForUsers).toHaveBeenCalledTimes(1);
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"));
});
});
describe("Verification requests", () => {
it("fetches device details before room verification requests", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive a room verification request
const event = mockedEvent("!r:s.co", "@u:s.co", "m.room.message", "m.key.verification.request");
await rustCrypto.onLiveEventFromSync(event);
// Then we first fetch device details
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalled();
// And we handle the verification event as normal
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
});
it("does not fetch device details before other verification events", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive some verification event that is not a room request
const event = mockedEvent("!r:s.co", "@u:s.co", "m.key.verification.start");
await rustCrypto.onLiveEventFromSync(event);
// Then we do not fetch device details
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
// And we handle the verification event as normal
expect(olmMachine.receiveVerificationEvent).toHaveBeenCalled();
});
it("throws an error if sender is missing", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive a verification event without a sender
// Then we throw
const event = mockedEvent("!r:s.co", null, "m.key.verification.start");
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
"missing sender in the event",
);
// And we do not fetch device details or handle the event
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
});
it("throws an error if room is missing", async () => {
// Given a RustCrypto
const olmMachine = mockedOlmMachine();
const outgoingRequestProcessor = mockedOutgoingRequestProcessor();
const rustCrypto = makeRustCrypto(olmMachine, outgoingRequestProcessor);
// When we receive a verification event without a sender
// Then we throw
const event = mockedEvent(null, "@u:s.co", "m.key.verification.start");
await expect(async () => await rustCrypto.onLiveEventFromSync(event)).rejects.toThrow(
"missing roomId in the event",
);
// And we do not fetch device details or handle the event
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
expect(olmMachine.receiveVerificationEvent).not.toHaveBeenCalled();
});
function mockedOlmMachine(): Mocked<OlmMachine> {
return {
queryKeysForUsers: jest.fn(),
getVerificationRequest: jest.fn(),
receiveVerificationEvent: jest.fn(),
} as unknown as Mocked<OlmMachine>;
}
function makeRustCrypto(
olmMachine: OlmMachine,
outgoingRequestProcessor: OutgoingRequestProcessor,
): RustCrypto {
const rustCrypto = new RustCrypto(
new DebugLogger(debug("test Verification requests")),
olmMachine,
{} as unknown as MatrixHttpApi<IHttpOpts & { onlyData: true }>,
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
// @ts-ignore mocking outgoingRequestProcessor
rustCrypto.outgoingRequestProcessor = outgoingRequestProcessor;
return rustCrypto;
}
function mockedOutgoingRequestProcessor(): OutgoingRequestProcessor {
return {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
}
function mockedEvent(
roomId: string | null,
senderId: string | null,
eventType: string,
msgtype?: string | undefined,
): MatrixEvent {
return {
isState: jest.fn().mockReturnValue(false),
getUnsigned: jest.fn().mockReturnValue({}),
isDecryptionFailure: jest.fn(),
isEncrypted: jest.fn(),
getType: jest.fn().mockReturnValue(eventType),
getRoomId: jest.fn().mockReturnValue(roomId),
getSender: jest.fn().mockReturnValue(senderId),
getId: jest.fn(),
getStateKey: jest.fn(),
getContent: jest.fn().mockReturnValue({ msgtype: msgtype }),
getTs: jest.fn(),
} as unknown as MatrixEvent;
}
});
});
/** Build a MatrixHttpApi instance */
@@ -2371,7 +2589,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,
@@ -156,6 +156,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -289,6 +291,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -405,6 +409,8 @@ describe("VerificationRequest", () => {
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
todoFixupVerificationRequestContent(verificationRequestContent);
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
@@ -460,6 +466,15 @@ describe("VerificationRequest", () => {
});
});
/**
* Needed until https://github.com/matrix-org/matrix-rust-sdk/issues/5643 is fixed.
*
* Modify the content of the supplied content to include `msgtype: m.key.verification.request`.
*/
function todoFixupVerificationRequestContent(content: any) {
content.msgtype = "m.key.verification.request";
}
describe("isVerificationEvent", () => {
it.each([
[EventType.KeyVerificationCancel],
+105
View File
@@ -26,6 +26,7 @@ import {
type ILeftRoom,
type IRoomEvent,
type IStateEvent,
type IStickyEvent,
type IStrippedState,
type ISyncResponse,
SyncAccumulator,
@@ -571,6 +572,49 @@ describe("SyncAccumulator", function () {
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
});
it("should delete knock state when room transitions from knock to join", () => {
const initKnockState = makeKnockState();
sa.accumulate(
syncSkeleton(
{},
{},
{},
{
knock_state: initKnockState,
},
),
);
expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState);
// Room transitions from knock to join (e.g., after approval and joining)
const joinState = {
account_data: { events: [] },
ephemeral: { events: [] },
unread_notifications: {},
state: {
events: [member("bob", KnownMembership.Join)],
},
};
const syncResponse = {
next_batch: "abc",
rooms: {
join: {
"!knock:bar": joinState,
},
invite: {},
leave: {},
},
} as unknown as ISyncResponse;
sa.accumulate(syncResponse);
expect(sa.getJSON().roomsData.knock["!knock:bar"]).toBeUndefined();
expect(sa.getJSON().roomsData.join["!knock:bar"].state?.events[0]?.content.membership).toEqual(
KnownMembership.Join,
);
});
it("should accumulate read receipts", () => {
const receipt1 = {
type: "m.receipt",
@@ -1024,6 +1068,67 @@ describe("SyncAccumulator", function () {
);
});
});
describe("MSC4354 sticky events", () => {
function stickyEvent(ts = 0): IStickyEvent {
const msgData = msg("test", "test text");
return {
...msgData,
msc4354_sticky: {
duration_ms: 1000,
},
origin_server_ts: ts,
};
}
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should accumulate sticky events", () => {
jest.setSystemTime(0);
const ev = stickyEvent();
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
});
it("should clear stale sticky events", () => {
jest.setSystemTime(1000);
const ev = stickyEvent(1000);
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
jest.setSystemTime(2000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
it("clears stale sticky events that pretend to be from the distant future", () => {
jest.setSystemTime(0);
const eventFarInTheFuture = stickyEvent(999999999999);
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
eventFarInTheFuture,
]);
jest.setSystemTime(1000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
});
});
function syncSkeleton(
+2 -2
View File
@@ -98,7 +98,7 @@ function createLinkedTimelines(): [EventTimeline, EventTimeline] {
describe("TimelineIndex", function () {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.getEventTimeline.mockResolvedValue(null);
});
describe("minIndex", function () {
@@ -193,7 +193,7 @@ describe("TimelineWindow", function () {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.getEventTimeline.mockResolvedValue(null);
mockClient.paginateEventTimeline.mockResolvedValue(false);
});
+5 -3
View File
@@ -187,12 +187,14 @@ describe("Group Call", function () {
});
it("does not start initializing local call feed twice", () => {
const promise1 = groupCall.initLocalCallFeed();
// @ts-expect-error TS2769
const spy = jest.spyOn(groupCall, "initLocalCallFeedInternal");
groupCall.initLocalCallFeed();
// @ts-ignore Mock
groupCall.state = GroupCallState.LocalCallFeedUninitialized;
const promise2 = groupCall.initLocalCallFeed();
groupCall.initLocalCallFeed();
expect(promise1).toEqual(promise2);
expect(spy).toHaveBeenCalledTimes(1);
});
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
+80 -6
View File
@@ -61,10 +61,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -77,7 +77,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
@@ -109,7 +109,7 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -122,10 +122,10 @@ describe("Media Handler", function () {
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
@@ -331,6 +331,80 @@ describe("Media Handler", function () {
expect(stream.getVideoTracks().length).toEqual(0);
});
it("falls back to ideal deviceId when exact deviceId fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.audio) {
const track = new MockMediaStreamTrack("audio_track", "audio");
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
const stream = await mediaHandler.getUserMediaStream(true, false);
// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { exact: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
it("falls back to ideal deviceId for video when exact fails", async () => {
// First call with exact should fail
mockMediaDevices.getUserMedia
.mockRejectedValueOnce(new Error("OverconstrainedError"))
.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.video) {
const track = new MockMediaStreamTrack("video_track", "video");
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
const stream = await mediaHandler.getUserMediaStream(false, true);
// Should have been called twice: once with exact, once with ideal
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledTimes(2);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { exact: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(mockMediaDevices.getUserMedia).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}),
);
expect(stream).toBeTruthy();
});
});
describe("getScreensharingStream", () => {
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
{
+1 -2
View File
@@ -102,8 +102,7 @@ export interface IRoomMemberCountCondition extends IPushRuleCondition<ConditionK
is: AnyMemberCountCondition;
}
export interface ISenderNotificationPermissionCondition
extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
export interface ISenderNotificationPermissionCondition extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
key: string;
}
+4 -1
View File
@@ -15,9 +15,12 @@ limitations under the License.
*/
import type { ISignatures } from "./signed.ts";
import type { EventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
// Backwards compatible re-export
export type { EventDecryptionResult as IEventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
/** @deprecated This is an internal type and should not be used. */
type IEventDecryptionResult = EventDecryptionResult;
export type { IEventDecryptionResult };
interface Extensible {
[key: string]: any;
+14 -3
View File
@@ -51,9 +51,14 @@ import {
type SDPStreamMetadata,
type SDPStreamMetadataKey,
} from "../webrtc/callEventTypes.ts";
import { type EncryptionKeysEventContent, type ICallNotifyContent } from "../matrixrtc/types.ts";
import {
type IRTCNotificationContent,
type IRTCDeclineContent,
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 RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
@@ -146,7 +151,10 @@ export enum EventType {
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
// MatrixRTC events
RTCMembership = "org.matrix.msc4143.rtc.member",
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline",
}
export enum RelationType {
@@ -325,9 +333,12 @@ export interface TimelineEvents {
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
[EventType.RTCDecline]: IRTCDeclineContent;
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
}
/**
@@ -360,7 +371,7 @@ export interface StateEvents {
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
+2 -5
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd, NamespacedValue, type Optional, UnstableValue } from "matrix-events-sdk";
import { type EitherAnd, NamespacedValue, UnstableValue } from "matrix-events-sdk";
import { isProvided } from "../extensible_events_v1/utilities.ts";
@@ -125,10 +125,7 @@ export type ExtensibleEventType = NamespacedValue<string, string> | string;
* @param expected - The expected event type.
* @returns True if the given type matches the expected type.
*/
export function isEventTypeSame(
given: Optional<ExtensibleEventType>,
expected: Optional<ExtensibleEventType>,
): boolean {
export function isEventTypeSame(given: ExtensibleEventType | null, expected: ExtensibleEventType | null): boolean {
if (typeof given === "string") {
if (typeof expected === "string") {
return expected === given;

Some files were not shown because too many files have changed in this diff Show More