Compare commits

..

265 Commits

Author SHA1 Message Date
RiotRobot 32c0b81332 v26.0.1 2023-06-09 09:25:30 +01:00
RiotRobot 634b8ebbb4 Prepare changelog for v26.0.1 2023-06-09 09:25:28 +01:00
Andy Balaam 4ab8066e1f Merge pull request #3460 from matrix-org/backport-3455-to-staging
[Backport staging] Fix: handle `baseUrl` with trailing slash in `fetch.getUrl`
2023-06-09 09:19:58 +01:00
Kerry 13dccb3d71 Fix: handle baseUrl with trailing slash in fetch.getUrl (#3455)
* tests

* tidy trailing slash in fetch.getUrl before forming url

* make sonar happy about Polynomial regular expression used on uncontrolled data

(cherry picked from commit ef1f5bf232)
2023-06-09 08:11:56 +00:00
RiotRobot f41fa84e72 v26.0.0 2023-06-06 13:44:07 +01:00
RiotRobot 3c4134537a Prepare changelog for v26.0.0 2023-06-06 13:44:03 +01:00
ElementRobot d4414341d6 v26.0.0-rc.1 2023-06-01 16:54:25 +01:00
ElementRobot f173014fc0 Prepare changelog for v26.0.0-rc.1 2023-06-01 16:54:22 +01:00
ElementRobot 81cb44db7f Merge branch 'develop' into staging
# Conflicts:
#	spec/unit/room.spec.ts
#	src/models/event-timeline.ts
#	src/models/thread.ts
2023-06-01 16:49:33 +01:00
Michael Telatynski 71f9b25db7 Ensure we do not add relations to the wrong timeline (#3427)
* Do not assume that a relation lives in main timeline if we do not know its parent

* For pagination, partition relations with unknown parents into a separate bucket

And only add them to relation map, no timelines

* Make addLiveEvents async and have it fetch parent events of unknown relations to not insert into the wrong timeline

* Fix tests not awaiting addLIveEvents

* Fix handling of thread roots in eventShouldLiveIn

* Fix types

* Fix tests

* Fix import

* Stash thread ID of relations in unsigned to be stashed in sync accumulator

* Persist after processing

* Revert "Persist after processing"

This reverts commit 05ed6409b35f5e9bea3b699d0abcaac3d02588c5.

* Update unsigned field name to match MSC4023

* Persist after processing to store thread id in unsigned sync accumulator

* Add test

* Fix replayEvents getting doubled up due to Thread::addEvents being called in createThread and separately

* Fix test

* Switch to using UnstableValue

* Add comment

* Iterate
2023-06-01 15:29:05 +00:00
Enrico Schwendig 9c5c7ddb17 Add remote user and call id in webrtc call stats (#3413)
* Refactor names in webrtc stats

* Refactor summary stats reporter to gatherer

* Add call and opponent member id to call stats reports

* Update opponent member when we know them

* Add missing return type

* remove async in test

* mark new stats property as optional to avoid braking changes
2023-06-01 15:07:44 +00:00
sigmaSd feb424b0a9 mention deno support in the README (#3417)
* mention deno support in the README

It seems to work fine, and I have a demo bot with it https://github.com/sigmaSd/deno-matrix-bot

* Update README.md
2023-06-01 07:52:55 +00:00
Travis Ralston 648a1a09b1 Mark room version 10 as safe (#3425)
As should have been done a year ago.
2023-05-31 14:15:49 +00:00
Michael Telatynski 56c5375bbd Remove spec non-compliant extended glob format (#3423)
* Remove spec non-compliant extended glob format

* Simplify

* Remove tests for non spec compliant behaviour

* Remove stale rules
2023-05-31 09:05:55 +00:00
Andy Balaam b29e1e9d21 Adjust fetchEditsWhereNeeded to use a clearer filter and async function (#3411)
* Make a clear and explicit filter on which events are considered for fetchEventsWhereNeeded

* Convert the logic in fetchEventsWhereNeeded to an async function
2023-05-26 08:29:35 +00:00
Enrico Schwendig 7ade461a4c Refactor naming of webrtc stats reporter (#3404)
* Refactor names in webrtc stats

* Refactor summary stats reporter to gatherer

* Add test for signal change

* rename test
2023-05-26 07:56:49 +00:00
Andy Balaam b5414ea914 Fix bug where original event was inserted into timeline instead of the edit event (#3398)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts

* Isolate Thread global modification in beforeAll()

* Delete unneeded setUnsigned call

* Use standard message-creation methods

* Rename event variables

* Rename sender->user

* Remove unneeded variables

* Extract distractions into functions

* Fetch edits for thread messages

Modifies fetchEditsWhereNeeded to allow edits of threaded messages. The
code before prevented any relations from fetching edits, but of course
events in threads are relations.

We definitely want thread messages to get their edits fetched, and I
assume this is working in the real code, probably because the event
being looked at is some kind of eventmapped thing that doesn't have
proper relations visible on it.

In tests, if we don't make this change, we can't see edits getting
fetched.

* Add a test for fetching edits on demand in a thread

This test demonstrates the current behaviour, which contains a bug - we
don't actually add the right event to the timeline.

* Fix bug where original event was inserted into timeline instead of the edit event
2023-05-25 15:48:48 +00:00
Andy Balaam 711bf4710d Test inserting edit events into the thread timeline "on demand" (#3410)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts

* Isolate Thread global modification in beforeAll()

* Delete unneeded setUnsigned call

* Use standard message-creation methods

* Rename event variables

* Rename sender->user

* Remove unneeded variables

* Extract distractions into functions

* Fetch edits for thread messages

Modifies fetchEditsWhereNeeded to allow edits of threaded messages. The
code before prevented any relations from fetching edits, but of course
events in threads are relations.

We definitely want thread messages to get their edits fetched, and I
assume this is working in the real code, probably because the event
being looked at is some kind of eventmapped thing that doesn't have
proper relations visible on it.

In tests, if we don't make this change, we can't see edits getting
fetched.

* Add a test for fetching edits on demand in a thread

This test demonstrates the current behaviour, which contains a bug - we
don't actually add the right event to the timeline.
2023-05-25 16:32:42 +01:00
Andy Balaam 48b60bb885 Simplify thread-editing test (#3409)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts

* Isolate Thread global modification in beforeAll()

* Delete unneeded setUnsigned call

* Use standard message-creation methods

* Rename event variables

* Rename sender->user

* Remove unneeded variables

* Extract distractions into functions
2023-05-25 14:03:58 +00:00
Andy Balaam 7ed14dc749 Only add a local receipt if it's after an existing receipt (#3399)
* Add a test for creating local echo receipts in threads

* Only add local receipt if it's after existing receipt

* Refactor local receipt tests to be shorter

* Tests for local receipts where we DO have recursive relations support
2023-05-25 13:59:14 +00:00
Andy Balaam 26736b6bb1 Move editing test into thread.spec.ts (#3408)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts
2023-05-25 13:20:59 +00:00
Andy Balaam 056aae823d Fix an existing test for editing messages in threads (#3407)
While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.
2023-05-25 12:38:53 +00:00
Andy Balaam d1bfdca0c9 Isolate changes to Thread global into a single describe block (#3390) 2023-05-25 11:45:47 +00:00
Andy Balaam c0577c29c4 Minor improvements to the code inserting events into timelines (#3389)
* Remove unneeded pling

* Comment clarifying why we bail out in insertEventIntoTimeline
2023-05-25 11:44:56 +00:00
Richard van der Hoff a2d1dee0a1 Minor fixes to CryptoApi doc comments (#3406)
Followups to #3287 and #3360: minor clarifications to the doc-comments
2023-05-24 16:02:40 +00:00
Richard van der Hoff d7bcdff29b Fix re-exports for SAS.ts (#3405)
These three are only types, not objects we can export.

Fixes warnings in EW (and probably some build failures for someone somewhere):

```
2023-05-24 11:27:28.294 [element-js] WARNING in ../matrix-js-sdk/src/crypto/verification/SAS.ts 31:0-123
2023-05-24 11:27:28.294 [element-js] "export 'EmojiMapping' was not found in '../../crypto-api/verification'
2023-05-24 11:27:28.294 [element-js]
2023-05-24 11:27:28.294 [element-js] WARNING in ../matrix-js-sdk/src/crypto/verification/SAS.ts 31:0-123
2023-05-24 11:27:28.294 [element-js] "export 'GeneratedSas' (reexported as 'IGeneratedSas') was not found in '../../crypto-api/verification'
2023-05-24 11:27:28.294 [element-js]
2023-05-24 11:27:28.294 [element-js] WARNING in ../matrix-js-sdk/src/crypto/verification/SAS.ts 31:0-123
2023-05-24 11:27:28.294 [element-js] "export 'ShowSasCallbacks' (reexported as 'ISasEvent') was not found in '../../crypto-api/verification'
```
2023-05-24 10:54:10 +00:00
Eric Eastwood ed71cdeccd Add more context for why threaded vs unthreaded read receipts (#3378)
* Add more context for why threaded vs unthreaded read receipts

See https://github.com/matrix-org/matrix-js-sdk/pull/3339#discussion_r1195364120

* Language updates from Andy

See https://github.com/matrix-org/matrix-js-sdk/pull/3378#discussion_r1197622113

* Fix lints
2023-05-24 10:48:22 +00:00
Michael Telatynski 729f924de1 Prioritise entirely supported flows for UIA (#3402)
* Prioritise entirely supported flows for UIA

* Add tests

* Fix types

* Apply suggestions from code review

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

* Update src/interactive-auth.ts

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-05-24 10:33:48 +00:00
Andy Balaam 4732098731 Test for inserting events into the timeline in timestamp order (#3391)
* Move mkReaction into test-utils so it can be used by other code

* Basic test for inserting messages into the thread timeline
2023-05-24 10:29:57 +00:00
ElementRobot a7d503a8ac [Backport staging] Fix mark as unread button (#3401)
Co-authored-by: Michael Weimann <michaelw@matrix.org>
2023-05-24 09:02:18 +01:00
Michael Weimann 063d69eff1 Fix mark as unread button (#3393)
* Fix mark as unread button

* Revert to prefer the last event from the main timeline

* Refactor room test

* Fix type

* Improve docs

* Insert events to the end of the timeline

* Improve test doc
2023-05-23 20:34:18 +00:00
Richard van der Hoff 614f446361 Combine QrCodeEvent, SasEvent and VerificationEvent (#3386)
* Move IReciprocateQr to `crypto-api` and rename

* Move ISasEvent to `crypto-api`, and rename

... and add some comments

* Combine QrCodeEvent, SasEvent and VerificationEvent together

... as a precursor to extracting a single `Verifier` interface for `SAS` and `ReciprocateQRCode`.

`enum`s are slightly magical things that have both a type and a value, so we
have to re-export their backwards-compatibility fudges twice.

* Update src/crypto/verification/Base.ts
2023-05-22 11:02:10 +00:00
Michael Telatynski b62fac9dad Update client.ts 2023-05-19 16:39:02 +01:00
ElementRobot ff07cb642f v25.2.0-rc.5 2023-05-19 16:35:34 +01:00
ElementRobot fba3cf2756 Prepare changelog for v25.2.0-rc.5 2023-05-19 16:35:32 +01:00
Michael Telatynski 5973c66726 Make sonar happier about our code & tests (#3388) 2023-05-19 16:33:19 +01:00
ElementRobot b22fc1f9d9 [Backport staging] Attempt a potential workaround for stuck notifs (#3387)
Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2023-05-19 15:57:55 +01:00
Richard van der Hoff 3f48a954d8 Move crypto classes into a separate namespace (#3385)
* Move crypto classes into a separate namespace

* Add in re-exports for backwards compatibility

* Update src/matrix.ts
2023-05-19 12:43:16 +00:00
Michael Telatynski b5d544df68 Update sonarqube.yml (#3376) 2023-05-19 12:16:29 +00:00
Andy Balaam cd26ba67d4 Attempt a potential workaround for stuck notifs (#3384)
* Attempt a potential workaround for stuck notifs

* Remove TODOs

* Fix backwards logic about server support for MSC3981 in fetchEditsWhereNeeded

* Check for lack of MSC3981 server support before calling insertEventIntoTimeline

* If no parent event is found, insert purely based on timestamp

* Mark temporary methods as internal
2023-05-19 12:34:26 +01:00
Michael Telatynski 488390365a Add typedoc-plugin-coverage (#3379)
* Add typedoc-plugin-coverage

* Specify coverageLabel
2023-05-19 10:35:19 +00:00
Richard van der Hoff b7b1129478 Fix bug with pendingEventOrdering: "chronological" (#3382)
If we don't do this, then we end up trying to match the MAC on our own
`m.key.verification.mac`, which of course fails.
2023-05-18 13:47:24 +00:00
Richard van der Hoff 0fa9528a3f Comments for typed-event-emitter classes (#3380)
* Comments for typed-event-emitter classes

* Export `TypedEventEmitter`

... to stop tsdoc complaining.
2023-05-18 12:22:51 +00:00
Enrico Schwendig a7b1dcaf95 Filter all summary stats which collect the first time webrtc metrics. (#3377)
* remove comment

* Filter all summery stats which collect the first time webrtc stats

* Fix lint issues

* Fix lint second issues
2023-05-17 15:40:33 +00:00
Richard van der Hoff bb5bccbf78 Element-R: initial implementation of bootstrapCrossSigning (#3368)
* Working `bootstrapCrossSigning` for rust

* Remove unused `oldBackendOnly`

* update tests

* another test
2023-05-16 20:30:32 +00:00
renovate[bot] ece3ccb958 Lock file maintenance (#3375)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 16:31:36 +00:00
renovate[bot] a769cf88f7 Update dependency eslint-plugin-unicorn to v47 (#3373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 15:23:34 +00:00
renovate[bot] 8b0f1a0c59 Update dependency @types/node to v18.16.9 (#3370)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-05-16 15:19:43 +00:00
renovate[bot] 978e748246 Update dependency eslint-plugin-jsdoc to v44 (#3372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 15:15:21 +00:00
renovate[bot] 32a5cc4728 Update matrix-org/netlify-pr-preview action to v2 (#3374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 15:01:13 +00:00
renovate[bot] d580f56c0b Update all non-major dependencies (#3371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 14:51:21 +00:00
Michael Telatynski 789458e0d4 Add methods to terminate idb worker (#3362) 2023-05-16 16:05:55 +01:00
renovate[bot] 8d14d45272 Update typescript-eslint monorepo to v5.59.6 (#3334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 14:37:05 +00:00
ElementRobot 2ec1fa6605 v25.2.0-rc.4 2023-05-16 14:22:04 +01:00
ElementRobot f15d682938 Prepare changelog for v25.2.0-rc.4 2023-05-16 14:22:02 +01:00
ElementRobot 21a10a2d14 v25.2.0-rc.3 2023-05-16 14:17:04 +01:00
ElementRobot fc02e550bd Prepare changelog for v25.2.0-rc.3 2023-05-16 14:17:02 +01:00
Michael Telatynski 78637a0689 Fix docs deployment 2023-05-16 14:15:27 +01:00
ElementRobot 4ca882fcd4 v25.2.0-rc.2 2023-05-16 13:58:35 +01:00
ElementRobot 13ee0eb7f5 Prepare changelog for v25.2.0-rc.2 2023-05-16 13:58:33 +01:00
ElementRobot cb018dfc80 Fix tsconfig-build.json 2023-05-16 13:58:05 +01:00
ElementRobot 7574dacdb3 v25.2.0-rc.1 2023-05-16 13:37:19 +01:00
ElementRobot 0c417b7c32 Prepare changelog for v25.2.0-rc.1 2023-05-16 13:37:17 +01:00
ElementRobot daf845d7bd Fix changelog_head.py script to be Python 3 compatible 2023-05-16 13:36:40 +01:00
Michael Telatynski 52792ec89b Fix CI failure on develop due to force merged PR and prettier failure (#3369) 2023-05-16 11:52:58 +00:00
Matthew Hodgson 6dc4a62e8c Merge pull request #3367 from matrix-org/matthew/fix-accumulated-sync-summaries
fix accumulated sync summaries - andy review
2023-05-16 12:34:54 +01:00
Matthew Hodgson 1cd8ea61ea merge 2023-05-16 12:08:02 +01:00
Matthew Hodgson 0c5eb277e4 incorporate andy review 2023-05-16 12:05:56 +01:00
Matthew Hodgson d459a91af3 correctly accumulate sync summaries. (#3366)
if a sync summary for (say) invited_member_count goes from 1 to 0, it should be
accumluated as 0, rather than 1.

Should fix https://github.com/vector-im/element-web/issues/23345
2023-05-16 10:41:35 +00:00
Matthew Hodgson 18722d0031 correctly accumulate sync summaries.
if a sync summary for (say) invited_member_count goes from 1 to 0, it should be
accumluated as 0, rather than 1.

Should fix https://github.com/vector-im/element-web/issues/23345
2023-05-16 11:34:06 +01:00
RiotRobot 5119934268 Merge branch 'master' into develop 2023-05-16 09:12:28 +01:00
RiotRobot 077da23d08 v25.1.1 2023-05-16 09:10:34 +01:00
RiotRobot 083b4cb17e Prepare changelog for v25.1.1 2023-05-16 09:10:31 +01:00
Richard van der Hoff 4316009401 Element-R: support for SigningKeysUploadRequest (#3365)
* OutgoingRequestProcessor: support for SigningKeysUploadRequest

* Tests

* Bump matrix-org/matrix-sdk-crypto-js

... to pick up bug fixes for outgoing requests
2023-05-15 19:14:05 +00:00
Richard van der Hoff 72f3c360b6 Add CryptoApi.getCrossSigningKeyId (#3360) 2023-05-15 18:46:33 +01:00
Enrico Schwendig fcbc195fbe Check permission on mute mic, only if no audio track exists. (#3359)
* check permission only if no audio track

* fix linter issues

* add missing tests for perfect negotiation pattern

* add null case in unit tests for audio muting

* fix issue with type MediaStream

* force right type of mock methode

* format code
2023-05-15 14:38:43 +00:00
Richard van der Hoff af38021d28 Deprecate device methods in MatrixClient (#3357) 2023-05-15 10:37:35 +00:00
Michael Telatynski 5e8cb9fa18 Fix typedoc release documentation deployment (#3358)
* Prune typedoc docs before generating new ones

* Only maintain 10 major versions

* Switch to deploy mechanism which doesn't mangle symlinks

* Convert absolute symlinks to relative
2023-05-15 09:14:35 +00:00
Michael Telatynski 6ef9f6c55e Enable better tree shaking (#3356) 2023-05-15 08:10:44 +00:00
Timo e6a3b0ebc0 Total summary count (#3351)
* add audio concealment to stats report

* audio concealment to summary

* make ts linter happy

* format and rename

* fix and add tests

* make it prettier!

* we can make it even prettier ?!

* review

* fix tests

* pretty

* one empty line to ...

* remove ratio in audio concealment (ratio is now done in the summary)

* remove comment

* fix test

* add peer connections to summary report

* tests
2023-05-14 16:58:38 +00:00
Robin aaae55736f Keep measuring a call feed's volume after a stream replacement (#3361) 2023-05-13 17:54:29 +00:00
Timo 9e586ab634 Audio concealment (#3349)
* add audio concealment to stats report

* audio concealment to summary

* make ts linter happy

* format and rename

* fix and add tests

* make it prettier!

* we can make it even prettier ?!

* review

* fix tests

* pretty

* one empty line to ...

* remove ratio in audio concealment (ratio is now done in the summary)

* remove comment

* fix test
2023-05-12 16:24:27 +00:00
Richard van der Hoff 7ff44d4a50 Integration test for bootstrapCrossSigning (#3355)
* Stub implementation of bootstrapCrossSigning

* Integration test for `bootstrapCrossSigning`
2023-05-12 16:19:18 +00:00
Richard van der Hoff 63abd00ca7 Element-R: Stub out isCrossSigningReady and isSecretStorageReady (#3354)
* Stub implementation of `isCrossSigningReady`

* Stub implementation of `isSecretStorageReady`

* add tests to meet quality gate

* factor out common

* Remove accidentally-added file
2023-05-12 12:21:52 +00:00
Richard van der Hoff 40f2579158 Pass SecretStorage into RustCrypto (#3353)
* Pass SecretStorage into RustCrypto

* Update src/rust-crypto/rust-crypto.ts
2023-05-12 09:38:33 +00:00
Richard van der Hoff ceb2a57feb Rename and move crypto.IBootstrapCrossSigningOpts (#3352)
* Define `UIAuthCallback` type and use in `IBootstrapCrossSigningOpts`

* Move `IBootstrapCrossSigningOpts` to `crypto-api` and rename

* Replace uses of `IBootstrapCrossSigningOpts`

... with `BootstrapCrossSigningOpts`

* Update src/crypto-api.ts
2023-05-11 18:41:58 +00:00
Enrico Schwendig 90e8336797 Do an ICE Restart if WebRTC become disconnected (#3341)
* Do an ice restart if ICE disconnected

  - Waite two seconds after disconnected
  - Remove check for finish ICE gathering and try to add each local candidate. Avoid race in multible ICE gathering

* Add tests for failed iceConnectionState

* suppress type check in unit test

* fix pr issues
2023-05-11 14:00:26 +00:00
Andy Balaam 73ca9c9ed2 Revert "Update JS-DevTools/npm-publish action to v2 (#3336)" (#3350)
Attempting to fix failure to publish to NPM due to an "invalid token"
error https://github.com/matrix-org/matrix-js-sdk/actions/runs/4926808655/jobs/8819822367 .

This reverts commit ca9263fa64.
2023-05-10 10:32:22 +00:00
RiotRobot cc065c2772 Resetting package fields for development 2023-05-09 15:14:29 +01:00
RiotRobot 028be0fee2 Merge branch 'master' into develop 2023-05-09 15:14:24 +01:00
RiotRobot d4500da59a v25.1.0 2023-05-09 15:12:52 +01:00
RiotRobot b6aef6772e Prepare changelog for v25.1.0 2023-05-09 15:12:49 +01:00
Michael Telatynski 49696cecbd Use WeakMap in ReEmitter to help enable garbage collection (#3344) 2023-05-09 13:24:34 +00:00
renovate[bot] 83cb52c89c Lock file maintenance (#3312)
* Lock file maintenance

* Fix types

* Iterate

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-05-09 13:11:30 +00:00
renovate[bot] e82bae2c4d Update dependency @types/node to v18.16.6 (#3347)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-09 10:52:45 +00:00
renovate[bot] ee2b0204aa Update dependency @types/node to v18.16.4 (#3335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-09 04:58:36 +00:00
renovate[bot] 1ec7670f6a Update babel monorepo (#3332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-08 02:47:30 +00:00
Michael Telatynski 8ab2e10471 Update types to match spec (#3330) 2023-05-05 08:13:07 +00:00
renovate[bot] 8ff8685ae5 Update dependency rimraf to v5 (#3298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-04 21:43:38 +00:00
Florian Duros f3772cdf82 Avoid upload a new fallback key at every /sync (#3338) 2023-05-03 13:10:02 +00:00
Andy Balaam ee2f1cdfd4 Accumulate receipts for the main thread and unthreaded separately. (#3339)
Fixes matrix-org/element-web#24629
2023-05-02 15:57:53 +00:00
Florian Duros 0de73a0b3e Update @matrix-org/matrix-sdk-crypto-js to ^0.1.0-alpha.8 (#3337) 2023-05-02 15:53:36 +00:00
renovate[bot] be742e811c Update dependency @types/jest to v29.5.1 (#3333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:58:33 +00:00
renovate[bot] ca9263fa64 Update JS-DevTools/npm-publish action to v2 (#3336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:56:42 +00:00
renovate[bot] 9f619be08d Update peter-evans/create-pull-request digest to 284f54f (#3331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:40:31 +00:00
RiotRobot 7792254c12 v25.1.0-rc.1 2023-05-02 11:31:10 +01:00
RiotRobot fff41f1f27 Prepare changelog for v25.1.0-rc.1 2023-05-02 11:31:04 +01:00
renovate[bot] 3e0f9f582e Update all non-major dependencies (#3329)
* Update all non-major dependencies

* Create typedoc.json

* Update typedoc-plugin-missing-exports

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-28 08:41:28 +00:00
Michael Telatynski 47ba8cfa24 Correct interactive-auth types (#3328) 2023-04-28 07:52:47 +00:00
renovate[bot] 54bb4c8011 Update all non-major dependencies (#3295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-27 23:55:40 +00:00
Kerry 71e763263e add client method to remove pusher (#3324)
* add client method to remove pusher

* remove unused type
2023-04-27 21:49:35 +00:00
Andy Balaam 2ebcda2a55 Tests for the ReceiptAccumulator (#3326) 2023-04-27 13:06:17 +00:00
Janne Mareike Koschinski e10db6f7e9 Implement MSC 3981 (#3248)
* Implement MSC 3891

* Add necessary mocks to tests

* Only set recurse parameter if supported

* fix: address review comments

* task: unify unstable prefix code between client and tests

* Add test for relations recursion

* Make prettier happier :)

* Revert "task: unify unstable prefix code between client and tests"

This reverts commit f7401e05

* Fix broken tests
2023-04-27 13:01:48 +00:00
Michael Weimann 1e041a2957 Add Room.getLastLiveEvent and Room.getLastThread (#3321)
* Add room.getLastLiveEvent and remove room.lastThread

* Deprecate Room.lastThread

* Add comments about timestamps

* Improve lastThread prop doc

* Simplify test structure
2023-04-27 11:44:27 +00:00
Florian Duros 1631d6f3c0 Fix racing between one-time-keys processing and sync (#3327) 2023-04-27 10:21:01 +00:00
Andy Balaam 3d86821258 Move receipt-specific logic into ReceiptAccumulator (#3320)
* Extract receipt accumulation logic into ReceiptAccumulator

* Rename readReceipts to unthreadedReadReceipts

* Move AccumulatedReceipt into receipt-accumulator

* Move the logic for consuming events into ReceiptAccumulator
2023-04-27 07:25:16 +00:00
Enrico Schwendig 261bc81554 Make webrtc stats collection optional (#3307)
* stats: disable stats collection if interval zero

* stats: add groupcall property for stats interval

* stats: disable collecting webrtc stats by default

* add setup methode for group call stats

* suppress lint errors in test
2023-04-27 06:56:58 +00:00
Michael Telatynski 8f701f43fb Use safeSet in recursivelyAssign (#3323) 2023-04-27 06:13:38 +00:00
Richard van der Hoff 4bdb9111dd Fix incorrect uses of IAuthData (#3322)
`IAuthData` is the response, not the request
2023-04-26 18:15:58 +00:00
Andy Balaam 93e2135223 Factor out ReceiptAccumulator (#3319)
* Performance tests for receipt accumulation

* Split ReceiptAccumulator into its own module
2023-04-26 15:07:28 +00:00
Enrico Schwendig 56cb05aac0 Adjust negotiation process for pending answer process (#3314)
* add debug statements

* adjust negotiation process

* switch tp simpler proof setLocalDescription()

* fix second race in answer pending state and renegotiation trigger

* revert simpler proof setLocalDescription because of pre SDP munging. I will refactor this in an extra PR
* add state of answer pending process on the second received answer methode as well. Now in any case of receiving answer we take care of this state.

* Clean up pending state in error case
2023-04-26 15:04:50 +00:00
renovate[bot] 38d5b202c9 Update peter-evans/create-pull-request action to v5 (#3300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-26 12:47:31 +00:00
David Baker cfffa9c518 Fix lack of media when a user reconnects (#3318)
* Fix lack of media when a user reconnects

This fixes broken media when someone reconnects to the call after
a forced disconnect (when their old call gets replaced immediately
by a new call). We listen for changes in the call feeds and the tearing
down of the feeds for the old call caused us to remove the feed for
the new call.

Also adds the call to the calls map before it'as initialised, such that
it's the active call for the user/device when the feedsChanged event arrives,
otherwise we'll ignore the event.

* Fix tests
2023-04-26 10:00:08 +00:00
Florian Duros 73dbd709d8 Element-R: Stub findVerificationRequestDMInProgress and getStoredCrossSigningForUser (#3315)
* Add `findVerificationRequestDMInProgress` into `CryptoBackend` and stub it `rust-crypto`

* Add `getStoredCrossSigningForUser` into `CryptoBackend` and stub it `rust-crypto`
2023-04-25 17:52:20 +01:00
Michael Telatynski eef67e2c03 Deprecate MatrixClient::resolveRoomAlias (#3316) 2023-04-25 15:39:01 +00:00
RiotRobot e3498f0668 Resetting package fields for development 2023-04-25 09:44:25 +01:00
RiotRobot f04c147faa Merge branch 'master' into develop 2023-04-25 09:44:20 +01:00
RiotRobot 33bbd45f1e v25.0.0 2023-04-25 09:42:44 +01:00
RiotRobot fd91a534c7 Prepare changelog for v25.0.0 2023-04-25 09:42:41 +01:00
Richard van der Hoff c2afc357b9 Add some comments to OlmDecryption.decryptEvent (#3308) 2023-04-24 11:25:07 +00:00
Richard van der Hoff 1d3f67f2ce DeviceVerificationStatus: add new signedByOwner property (#3311) 2023-04-24 10:41:36 +00:00
Michael Telatynski 514e4f07f1 Update sonarcloud.yml 2023-04-24 08:38:14 +01:00
Florian Duros fbb1c4b2bd Element-R: wire up device lists (#3272)
* Add `getUserDeviceInfo` to `CryptoBackend` and old crypto impl

* Add `getUserDeviceInfo` WIP impl to `rust-crypto`

* Add tests for `downloadUncached`

* WIP test

* Fix typo and use `downloadDeviceToJsDevice`

* Add `getUserDeviceInfo` to `client.ts`

* Use new `Device` class instead of `IDevice`

* Add tests for `device-convertor`

* Add method description for `isInRustUserIds` in `rust-crypto.ts`

* Misc

* Fix typo

* Fix `rustDeviceToJsDevice`

* Fix comments and new one

* Review of `device.ts`

* Remove `getUserDeviceInfo` from `client.ts`

* Review of `getUserDeviceInfo` in `rust-crypto.ts`

* Fix typo in `index.ts`

* Review `device-converter.ts`

* Add documentation to `getUserDeviceInfo` in `crypto-api.ts`

* Last changes in comments
2023-04-21 14:03:02 +00:00
Florian Duros 63dea599b1 Update @matrix-org/matrix-sdk-crypto-js to 0.1.0-alpha.7 (#3309) 2023-04-20 16:37:09 +00:00
Janne Mareike Koschinski 743ba5f050 Increase log level for scheduler (#3152) 2023-04-20 12:31:58 +00:00
Michael Telatynski cb180b4195 Improve types (#3305) 2023-04-20 08:02:38 +00:00
Richard van der Hoff c805b7e29d DeviceVerificationStatus: take a param object (#3303) 2023-04-19 14:38:23 +01:00
Michael Weimann 8f6814450b Add proper deprecation doc to room fields (#3291) 2023-04-19 08:08:37 +00:00
Michael Telatynski 37e8391cde Node 20 support (#3302) 2023-04-19 09:11:32 +01:00
Enrico Schwendig 90234402a7 stats: calculate received media and ignore not added tracks (#3301)
* stats: calculate received media by ignore not added tracks

* stats: fix lint issue

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-19 06:50:27 +00:00
renovate[bot] 8ecf603d73 Update dependency eslint-plugin-jsdoc to v41 (#3297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-04-19 03:11:41 +00:00
renovate[bot] af243581ff Update typescript-eslint monorepo to v5.58.0 (#3296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-18 22:10:19 +00:00
renovate[bot] 01afed9ff9 Update dawidd6/action-download-artifact digest to 246dbf4 (#3294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-18 22:10:19 +00:00
Michael Telatynski 2687bb37fb Update tests.yml 2023-04-18 17:51:06 +01:00
Michael Telatynski 65b1a10803 Fix TimelineWindow getEvents exploding if no neigbouring timeline (#3285) 2023-04-18 12:45:58 +00:00
Michael Telatynski 9d230ef0d6 Use typedoc-plugin-versions (#3289)
* Install typedoc-plugin-versions

* Iterate

* Remove typedoc.json

* Simplify

* Simplify

* gnu vs bsd

* Iterate

* Iterate
2023-04-18 12:39:17 +00:00
Richard van der Hoff a03438f2af New CryptoApi.getDeviceVerificationStatus api (#3287)
* Element-R: implement `{get,set}TrustCrossSignedDevices`

A precursor to https://github.com/vector-im/element-web/issues/25092

* Pull out new `DeviceVerificationStatus`

Define a new base class to replace `DeviceTrustLevel`. The intention is to have
a cleaner interface which is easier to expose from the new crypto impl

* Define, and implement, a new `CryptoApi.getDeviceVerificationStatus`

This is similar to `checkDeviceTrust`, which we're deprecating, but:
 * is `async`, meaning we can implement it in Rust
 * Returns a `DeviceVerificationStatus` instead of a `DeviceTrustLevel`
 * Returns `null` rather than "not verified" if the device is unknown

* add some tests

* Export DeviceVerificationStatus as a proper class

... so that we can instantiate it in tests
2023-04-18 10:52:13 +00:00
RiotRobot c622e9260f v25.0.0-rc.1 2023-04-18 11:41:03 +01:00
RiotRobot 8bf53d6f90 Prepare changelog for v25.0.0-rc.1 2023-04-18 11:41:00 +01:00
Enrico Schwendig 8c30a3b0df Add max jitter and max packet loss (#3293)
* stats: add max jitter and max packet loss

* stats: add test for max jitter and packet loss

* stats: add build summery report tests

* stats: switch to packetsLost instead of packetsTotal
2023-04-18 10:28:59 +00:00
Richard van der Hoff c61d53eed0 Element-R: implement {get,set}TrustCrossSignedDevices (#3281)
A precursor to https://github.com/vector-im/element-web/issues/25092
2023-04-18 10:28:47 +00:00
Michael Telatynski 95f7d1d347 Add typedoc-plugin-mdn-links (#3292) 2023-04-18 10:00:37 +00:00
Michael Telatynski 72d70bb929 Improve types and their safety (#3290)
* Improve types and their safety

* Iterate
2023-04-18 07:32:40 +00:00
Kerry 4f67e59692 Annotate events with executed push rule (#3284)
* unit test paginating /notifications

* add push rule to event

* 1% more test coverage
2023-04-17 21:35:56 +00:00
Kerry d40d5c8a39 unit test paginating /notifications (#3283) 2023-04-16 22:33:52 +00:00
Tulir Asokan 87398ac555 Fix screen sharing on Firefox 113 (#3282)
`getCapabilities` exists now(?), but `setCodecPreferences` doesn't,
which means it would throw an error and fail the call.

Signed-off-by: Tulir Asokan <tulir@maunium.net>
2023-04-15 05:08:21 +00:00
Michael Weimann de3d5ead42 Add Room.threadsTimelineSets doc (#3279)
* Add Room.threadsTimelineSets doc

* Tweak type checks

* Tweak type check again
2023-04-14 09:48:28 +00:00
Richard van der Hoff 1e1b571b28 Expose ServerSideSecretStorage independently of Crypto (#3280)
There is no reason to indirect secret storage via the Crypto layer, and
exposing it directly means it will work for Element-R.

Fixes: https://github.com/vector-im/element-web/issues/24982
2023-04-13 17:21:38 +01:00
Richard van der Hoff f400a7b1b2 Remove eventNames from TypedEventEmitter (#3276) 2023-04-13 10:43:57 +00:00
Enrico Schwendig a0bcb5777f Add Jitter to exported webrtc stats (#3270)
* stats: Add Jitter stats

* Update src/webrtc/stats/trackStatsReporter.ts

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

* stats: Fix typos in tests

* stats: differences between 0 and undefined in jitter val

---------

Co-authored-by: Robin <robin@robin.town>
2023-04-13 08:51:13 +00:00
Richard van der Hoff f8a625eddb OutgoingRoomKeyRequest is only a type (#3278)
Followup to #3275

Fixes a warning from webpack:

    [element-js] WARNING in ../matrix-js-sdk/src/matrix.ts 46:0-61
    [element-js] "export 'OutgoingRoomKeyRequest' was not found in './crypto/store/base'
2023-04-13 07:43:19 +00:00
Richard van der Hoff 69a2a15b95 Add missing methods to ServerSideSecretStorgage (#3277)
(and improve some doc-comments)

Seems like I missed a couple of methods when I created this interface in #3267.
2023-04-13 06:08:53 +00:00
Richard van der Hoff b9d0596dd7 Fix typedoc warnings (#3275)
There aren't that many of these, so I've gone through and fixed them, and
configured the GHA workflow to complain if any creep back in.
2023-04-12 20:30:57 +00:00
Richard van der Hoff 72af8c193c Typescript fix to AccountDataClient (#3274)
* Typescript fix to `AccountDataClient`

obviously, `getAccountDataFromServer` can return null

* Update src/secret-storage.ts

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-12 18:38:05 +00:00
Richard van der Hoff ed8c326856 Pass UserId into receiveSyncChanges (#3273) 2023-04-12 18:33:12 +00:00
Michael Telatynski f5bf6b1be6 Update types to match reality (#3271) 2023-04-12 17:49:16 +00:00
Richard van der Hoff 0e19f8dc69 Split SecretStorage into two parts (#3267)
* Pull `SecretStorageCallbacks` out of `ICryptoCallbacks`

* Pull the storage part of SecretStorage out to a new class

* Move SecretSharing to a separate class

* Move `ISecretRequest` into `SecretSharing.ts`

* Pull out ISecretStorage interface, and use it

* Mark old `SecretStorage` as deprecated, and rename accesses to it

* Move a `SecretStorage` unit test into its own file

* Use new `SecretStorage` in a couple of places

* add some more unit tests

* Fix test file name

... to match the unit under test

* even more tests

* Add a load of comments

* Rename classes

* Fix some broken tsdoc links

* fix broken test

* Fix compaints about superlinear regex

* just one more test
2023-04-12 16:10:43 +00:00
Richard van der Hoff 6049c0bf37 Improve output in github actions for jest tests (#3269) 2023-04-11 18:53:10 +00:00
texuf a102253f30 Update storage interface so that save() is async to match indexeddb impl (#3221)
The only implementation of this is an async function, but I can’t await it because the interface hides the return type.

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-04-11 18:52:53 +00:00
Florian Duros f71d86f005 Element-R: pass device list change notifications into rust crypto-sdk (#3254) 2023-04-11 14:42:19 +01:00
RiotRobot 70e34ffb76 Resetting package fields for development 2023-04-11 14:00:55 +01:00
RiotRobot 91aa7b26e6 Merge branch 'master' into develop 2023-04-11 14:00:49 +01:00
RiotRobot 7d45947fb3 v24.1.0 2023-04-11 13:59:08 +01:00
RiotRobot 6e174328e8 Prepare changelog for v24.1.0 2023-04-11 13:59:05 +01:00
renovate[bot] 5fb97fcce4 Lock file maintenance (#3268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-11 09:24:40 +00:00
Hugh Nimmo-Smith 3d1a450129 Export type for return of getCapabilities() (#3266)
* Export type for return of getCapabilities()

Renamed because it clashes with ICapabilities from embedded

* Export type for return of getCapabilities()

Renamed because it clashes with ICapabilities from embedded

* Rename to Capabilities
2023-04-06 12:46:44 +00:00
Michael Telatynski a58c5aacdf Update pull_request.yaml 2023-04-06 12:53:43 +01:00
Michael Weimann d7e165a279 Retry processing potential poll events after decryption (#3246)
* Retry processing potential poll events after decryption

* Point `typedoc` at `matrix.ts`, not `index.ts` (#3239)

This gets rid of the rather pointless "default" module in the generated docs.

* Split up, rename, and move `ISecretStorageKeyInfo` (#3242)

* Move SecretStorageKeyInfo interfaces out to a new module

* Replace usages of ISecretStorageKeyInfo with SecretStorageKeyDescription

* Skip clear text non-poll events

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-04-06 09:59:39 +00:00
renovate[bot] a57ee803f1 Update dependency typescript to v5 (#3262)
* Update dependency typescript to v5

* Update dependency typescript to v5

* Iterate

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-06 09:54:02 +00:00
Enrico Schwendig 170a52b09f Measure whether we receive media and add the mute state as an exception (#3249)
* stats: add summery stats reporter

* stats: export summery stats reports

* stats: fix typo of event name

* stats: check promise condition for node 16 test linter

* stats: remove weak test to figure out memory leak

* stats: remove second weak test

* stats: add starting processing test

* stats: fix tests

* stats: fix typo in group call

* stats: fix stats report gathering test

* stats: reactivate promise merge

* stats: add track counter and track mute counter in summary stats

* stats: add summery calculation

* stats: fix PR issues

* stats: adjust summery reporter for inbound and mute state

* stats: check async state

* stats: switch from an `Or` to `And` condition for entire received media value

* stats: Add property description

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-06 09:32:09 +00:00
renovate[bot] 2be5889d18 Update peter-evans/create-pull-request digest to 38e0b6e (#3257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-06 09:20:40 +00:00
Michael Telatynski ca6b574bee Update upgrade_dependencies.yml (#3264) 2023-04-06 07:51:58 +00:00
Michael Telatynski 57b0172a2d Update pull_request.yaml 2023-04-06 09:17:30 +01:00
Michael Telatynski 53260ee25d Update pull_request.yaml (#3255) 2023-04-06 07:45:44 +00:00
renovate[bot] 964281322f Update all non-major dependencies (#3259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 18:38:55 +00:00
David Baker 7c3f483396 Fix another export type (#3265)
Another case where we were importing types as normal imports which
confuses bundlers.
2023-04-05 17:14:14 +00:00
Hugh Nimmo-Smith 59784aa9fe Support for MSC3882 revision 1 (#3228)
* Support for MSC3882 revision 1

* Additional comments

* Revised field names

* Use UnstableValue for capability
2023-04-05 16:12:29 +00:00
Florian Duros 72a2b6d571 Use processKeyCounts in sliding sync (#3253) 2023-04-05 15:39:46 +00:00
renovate[bot] 5854af0eae Update definitelyTyped (#3260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:39:24 +00:00
renovate[bot] acd3d3a804 Update peaceiris/actions-gh-pages digest to 373f7f2 (#3256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:23:40 +00:00
renovate[bot] 1b8c04a430 Update babel monorepo (#3258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:12:15 +00:00
renovate[bot] 378b73f8b8 Update typescript-eslint monorepo to v5.57.0 (#3261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-05 15:10:56 +00:00
Michael Telatynski c482a6ab15 Tidy up merge queue automation (#3252)
* Tidy up merge queue automation

* Iterate

* Iterate

* Iterate
2023-04-05 13:48:57 +00:00
Florian Duros 2daa429b77 Improve #3215 implementation (#3226)
* Improve key upload request

* Add fallback keys check

* Review fixes

* Add comments about sliding sync usage of `processKeyCounts`

* Review fixes

* Better wording
2023-04-05 12:35:10 +00:00
Richard van der Hoff 6ebbc15359 Move SecretStorage-related interfaces out to new module (#3244)
* Remove redundant `IAccountDataClient.getAccountData`

This is never called, so we may as well get rid of it

* Move a few more interfaces into `secret-storage.ts`

* Use interfaces from `secret-storage`

* Move IAccountDataClient to secret-storage

* Use `AccountDataClient` from `secret-storage`

* move SECRET_STORAGE_ALGORITHM_V1_AES to secret-storage

* Use `SECRET_STORAGE_ALGORITHM_V1_AES` from `secret-storage`

* Add a test case for the quality gate

* Update src/secret-storage.ts
2023-04-05 11:42:15 +00:00
Richard van der Hoff 9a840d484c Element-R: handle events which arrive before their keys (#3230)
* minor cleanups to the crypto tests

mostly, this is about using `testUtils.awaitDecryption` rather than custom
code. Some other cleanups too.

* Keep a record of events which are missing their keys

* Retry event decryption when we receive megolm keys
2023-04-05 10:00:08 +00:00
David Baker e89467c9fb Add an event emitted when a Call creates a PeerConnection (#3251)
For apps that need acces to the per connection as soon as it's created
for debugging etc.
2023-04-05 08:32:50 +00:00
Enrico Schwendig 0b396c005c indicator whether call members send media. (#3241)
* stats: add summery stats reporter

* stats: export summery stats reports

* stats: fix typo of event name

* stats: check promise condition for node 16 test linter

* stats: remove weak test to figure out memory leak

* stats: remove second weak test

* stats: add starting processing test

* stats: fix tests

* stats: fix typo in group call

* stats: fix stats report gathering test

* stats: reactivate promise merge

* stats: fix PR issues

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-05 06:48:12 +00:00
David Baker d05313f95e Switch some imports to type imports (#3250)
Having these as regular imports confuses Vite for some reason.
2023-04-04 15:31:16 +00:00
RiotRobot 3f97853011 v24.1.0-rc.1 2023-04-04 11:53:22 +01:00
RiotRobot 2a5c4b1edf Prepare changelog for v24.1.0-rc.1 2023-04-04 11:53:18 +01:00
texuf 65a3c6707c indexddb-local-backend - return the current sync to database promise if a sync is in flight (#3222)
I’m trying to shutdown my matrix clients while using an indexdb, but awaiting the save() function has no effect because a previous sync was in flight. I ended up deleting the matrix client while the save was in flight and I saw a crash.

signed-off-by Austin Ellis <austin@hntlabs.com>

fix linter
2023-04-04 09:36:44 +00:00
Enrico Schwendig 942477f0bf screen share: remove set stream (#3238)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-04 08:53:32 +00:00
Michael Telatynski 1770b3131a Skip sonarcloud & coverage in merge_queue (#3247) 2023-04-04 08:46:41 +01:00
Richard van der Hoff 41d3ffdab9 Split up, rename, and move ISecretStorageKeyInfo (#3242)
* Move SecretStorageKeyInfo interfaces out to a new module

* Replace usages of ISecretStorageKeyInfo with SecretStorageKeyDescription
2023-04-03 10:11:03 +00:00
Richard van der Hoff 78aa6cb62b Point typedoc at matrix.ts, not index.ts (#3239)
This gets rid of the rather pointless "default" module in the generated docs.
2023-04-03 09:55:11 +00:00
Michael Telatynski b56b8040e6 Use frozen lockfile instead of pure lockfile on yarn install (#3245) 2023-03-31 14:49:49 +00:00
Andy Balaam d1cf98b177 Allow via_servers property in findPredecessor (update to MSC3946) (#3240) 2023-03-31 12:51:10 +00:00
Michael Telatynski 5fc6b3ed17 Fire closed event when IndexedDB closes unexpectedly (#3218)
* Fire `closed` event when IndexedDB closes unexpectedly

* Add test

* Add tests

* Add test

* Add test

* Add test coverage
2023-03-31 08:46:11 +00:00
renovate[bot] 6e41533fed Update jest monorepo to v29.5.0 (#3204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-31 07:52:07 +00:00
David Baker bc76532bd5 Refactor the way group calls hang up (#3234)
* Refactor how group call end calls

We previously used disposeCall to terminate the call which meant that
sometimes a call would never get a hangup event. This changes it so
that we always end a call by calling hangup, then do the cleanup
when the hangup event arrives, so the cleanup is the same whether
we hang up or the other side does.

* Some fixes for failing & hanging tests

* Add type for the call map
2023-03-30 15:57:47 +00:00
Enrico Schwendig 62f1dd79bc Add webrtc stats in full mesh calls (#3232)
* stats: merge stats classes in this branch

* stats: merge call object

* stats: merge export metric events

* stats: fix code style changes

* stats: add missing stats value formatter

* stats: add missing methode to call mock

* stats: add stats for callee

* stats: stop sending stats if call finish

* stats: rename StatsCollector to StatsReportGatherer
2023-03-30 15:17:59 +00:00
David Baker fd3f53e814 Merge pull request #3237 from matrix-org/dbkr/call_events_pass_call_2
Re-apply "Add the call object to Call events"
2023-03-30 10:22:17 +01:00
David Baker 798ac7b94c Revert "Revert "Add the call object to Call events"" 2023-03-29 15:32:25 +01:00
Richard van der Hoff eb0c0f7b93 Element-R: Add support for /discardsession (#3209)
Fixes https://github.com/vector-im/element-web/issues/24431
2023-03-29 13:26:02 +00:00
David Baker f03293f53d Merge pull request #3236 from matrix-org/revert-3229-dbkr/call_events_pass_call
Revert "Add the call object to Call events"
2023-03-29 14:41:59 +01:00
David Baker 7d062387b7 Revert "Add the call object to Call events" 2023-03-29 14:27:49 +01:00
Andy Balaam 9acf3b18ca Update changelog for v24.0.0 now the security issue is public (#3235) 2023-03-29 13:06:32 +00:00
David Baker b41d067c94 Merge pull request #3229 from matrix-org/dbkr/call_events_pass_call
Add the call object to Call events
2023-03-29 12:26:34 +01:00
David Baker c8503b3120 Run prettier on groupCall.ts (#3233) 2023-03-28 15:35:00 +00:00
David Baker da03c3b529 Fix an issue where participants could potentially view video without being displayed themselves
Fix from @robintown, manual merge due to Github having issues.
2023-03-28 15:20:34 +01:00
Robin d48b19e052 Handle group call redaction (#3231)
Redacted group call events should be interpreted as terminated calls.
2023-03-28 13:07:44 +00:00
RiotRobot 6861c67f56 Merge branch 'master' into develop 2023-03-28 14:15:09 +01:00
RiotRobot c87048bd9f v24.0.0 2023-03-28 14:10:53 +01:00
RiotRobot 02269f33b7 Prepare changelog for v24.0.0 2023-03-28 14:10:50 +01:00
Michael Weimann 7f46ae7b97 Further changes for v24.0.0 2023-03-28 14:05:24 +01:00
David Baker 2ab3566f95 Add comment on confusing exception handler I've failed to add a test for 2023-03-28 13:47:22 +01:00
Michael Weimann 9a504af18e Changes for v24.0.0 2023-03-28 11:22:02 +01:00
David Baker 8b50986906 Add test for incoming data channel 2023-03-28 09:30:17 +01:00
David Baker f9a222ecea Mock out media getter after setting up the vocie call
Otherwise we won't get far enough to test the right part
2023-03-27 16:18:00 +01:00
David Baker 5b5a3d8b5e Test failed call upgrade 2023-03-27 15:44:56 +01:00
David Baker 037cbdd214 Basic test for call replace 2023-03-27 14:33:58 +01:00
David Baker 0349411e6d Rename group call error event
So it doesn't clash with the call error event
2023-03-24 14:26:03 +00:00
David Baker d7b75e4b9e Add the call object to Call events
As explained in the comment. I've added it to the end so this should
be completely backwards compatible (although it would be much nicer
if it were the first arg, probably).
2023-03-24 14:09:22 +00:00
Richard van der Hoff 254f043ab0 Enable the prepareToEncrypt test for Element-R (#3225)
This Just Works, so I don't know why I didn't enable the test sooner.
2023-03-24 11:55:17 +00:00
J. Ryan Stinnett 5f3e115545 Stop doing O(n^2) work to find event's home (#3227)
* Stop doing O(n^2) work to find event's home

In certain rooms (e.g. with many state changes hidden via user preferences), the
events array presented to `eventShouldLiveIn` may contain 100s of events. As
part of its various checks, `eventShouldLiveIn` would get an event's associated
ID (reply / relation / redaction parent). It would then use `events.find` to
search the entire (possibly large) `events` array to look for the parent. (This
by itself seems sub-optimal and should probably change to use a map.)

For many events in a room, there is no associated ID. Unfortunately,
`eventShouldLiveIn` did not check whether the associated ID actually exists
before running off to search all of `events`, resulting in O(n^2) work.

This changes `eventShouldLiveIn` to first check that there is an associated ID
before proceeding with its (slow) search. For some rooms, this change
drastically improves performance from ~100% CPU usage to nearly idle.

Signed-off-by: J. Ryan Stinnett <jryans@gmail.com>

* Add type to `parentEvent`

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: J. Ryan Stinnett <jryans@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-03-23 19:02:55 +00:00
Patrick Cloke fc55c4c72a Implement MSC3952: intentional mentions (#3092)
* Add experimental push rules.

* Update for changes to MSC3952: Use event_property_is and event_property_contains.

* Revert custom user/room mention conditions.

* Skip legacy rule processing if mentions exist.

* Add client option for intentional mentions.

* Fix tests.

* Test leagcy behavior with intentional mentions.

* Handle simple review comments.
2023-03-22 20:22:34 +00:00
Florian Duros f795577e14 Send one time key count and unused fallback keys for rust-crypto (#3215)
* Send one time key count and unused fallback keys for rust-crypto

* Add tests

* Remove useless type in promise return

* Add test for one time key upload

* Fix rust-crypto.spec.ts tests

* Remove unneeded code in test

* Add key upload request test

* Fix tests
2023-03-22 10:19:04 +00:00
Eric Eastwood f12cee984a Export TimestampToEventResponse to use in matrix-react-sdk tests (#3223)
* Export ITimestampToEventResponse to use in matrix-react-sdk tests

Part of https://github.com/matrix-org/matrix-react-sdk/pull/10405

* Remove I from interface

As suggested by @weeman1337,
https://github.com/matrix-org/matrix-js-sdk/pull/3223#pullrequestreview-1350612347

See code style guide, https://github.com/vector-im/element-web/blob/50f8be4a623d2280a72920eb1679a4754961f807/code_style.md#typescript--javascript-typescript-javascript

> Interface names should not be marked with an uppercase `I`.
2023-03-21 17:10:28 +00:00
Richard van der Hoff c3b4572841 Add a new test for event encryption, which works with rust (#3203)
* crypto.spec.ts: factor out `expactAliceKeyClaim` utility

* Add a new test for event encryption

... one that actually works on the rust SDK.

* Bump matrix-sdk-crypto-js version

... to pick up recent fixes to race conditions
2023-03-21 16:24:57 +00:00
David Baker 40fe159c10 Merge pull request #3219 from robintown/matrix-widget-api
Update matrix-widget-api
2023-03-21 10:59:50 +00:00
Robin Townsend ddecc87947 Update matrix-widget-api 2023-03-20 10:28:46 -04:00
David Baker 23837266fc Merge pull request #3217 from matrix-org/dbkr/type_sendvoipevent
Add a type to the structure used by the SendVoipEvent event
2023-03-16 10:52:09 +00:00
David Baker 3c9ca8c373 Add a type to the structure used by the SendVoipEvent event 2023-03-15 16:07:10 +00:00
RiotRobot 7f2a4c2568 Resetting package fields for development 2023-03-15 12:41:24 +00:00
RiotRobot 2ad647a73c Merge branch 'master' into develop 2023-03-15 12:41:18 +00:00
David Baker 7faba5c2f0 Merge pull request #3213 from matrix-org/dbkr/fix_starting_with_video_muted
Fix bug where video would not unmute if it started muted
2023-03-14 11:18:39 +00:00
Michael Telatynski 1d9250b277 Fix CallOpts types (#3214) 2023-03-14 09:53:59 +00:00
David Baker 08054c1d6d Fix bug where video would not unmute if it started muted
Fixes https://github.com/vector-im/element-call/issues/925
2023-03-13 17:04:43 +00:00
Richard van der Hoff 333872e878 Pull out a public CryptoApi (#3211)
... so that we don't have to implement annoying shims in `MatrixClient`
2023-03-13 12:02:49 +00:00
Janne Mareike Koschinski 913cd257f4 Pin versions of third-party github actions (#3208) 2023-03-10 12:56:23 +00:00
Richard van der Hoff 69f7789c40 Merge pull request #3202 from matrix-org/rav/element-r/encryption_fixes
Fixes to event encryption in the Rust Crypto implementation
2023-03-10 11:00:18 +00:00
renovate[bot] e79ef1f33a Update typescript-eslint monorepo to v5.54.0 (#3198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-03-10 09:06:58 +00:00
renovate[bot] fb8f61a5ec Update dependency eslint-plugin-unicorn to v46 (#3199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-09 21:28:20 +00:00
Kerry 9b1f2a1d11 adjust beacon model to make beaconInfo definitely assigned in constructor (#3201)
* adjust beacon model to make beaconInfo definitely assigned

* fix

* improvement from PR
2023-03-09 21:03:42 +00:00
Richard van der Hoff 686216fb75 Merge branch 'develop' into rav/element-r/encryption_fixes 2023-03-09 13:34:17 +00:00
Richard van der Hoff f4b83e1a27 Stop a failed /keys/claim request getting stuck
Putting the new request inside a `finally` block meant we would never actually
transition the promise chain from failure to success. Sticking a no-op `catch`
in the chain makes sure that we can recover from an error.
2023-03-09 10:54:50 +00:00
Richard van der Hoff 97f21b6635 Send the outgoing m.room_key messages returned by shareRoomKey
I forgot this in https://github.com/matrix-org/matrix-js-sdk/pull/3122 :(.

To be honest, I'm not sure how it ever worked.
2023-03-09 10:50:49 +00:00
Michael Telatynski 87641a6803 Improve processBeaconEvents hotpath (#3200)
* Attempt at improving beacons hotpath

* Iterate and fix tests
2023-03-09 09:24:57 +00:00
renovate[bot] 7e4331172a chore(deps): update all non-major dependencies (#3197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-08 15:33:44 +00:00
renovate[bot] a976080d1b chore(deps): update dependency @types/node to v18.14.6 (#3196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-08 08:16:55 +00:00
Patrick Cloke bcf3bba44e Implement MSC3966: a push rule condition to check if an array contains a value (#3180)
* Support MSC3966 to match values in an array in push rule conditions.

* Update to stable identifiers.

* Appease the linter.
2023-03-07 16:36:06 +00:00
Michael Telatynski 54ac36d424 Revert "Add GHA for requiring all reviews to be dismissed or approving before merging (#2391)" (#3195)
This reverts commit e84c90dbbc.
2023-03-07 15:21:20 +00:00
Michael Telatynski e84c90dbbc Add GHA for requiring all reviews to be dismissed or approving before merging (#2391)
* Add GHA for requiring all reviews to be dismissed or approving before merging

* Attempt again

* Try try try again

* stash

* Stash

* Dummy

* Fix indentation

* Iterate

* Tweak

* Iterate

* toJSON

* Iterate

* Iterate

* Fix

* typo

* Permissions

* tidy

* Update pending_reviews.yml

* delint
2023-03-07 14:23:56 +00:00
Michael Telatynski 4424438658 Fix jest/no-conditional-expect lint and enable it (#3194) 2023-03-07 12:44:03 +00:00
197 changed files with 14598 additions and 4191 deletions
-3
View File
@@ -69,9 +69,6 @@ module.exports = {
// TODO: There are many tests with invalid expects that should be fixed,
// https://github.com/matrix-org/matrix-js-sdk/issues/2976
"jest/valid-expect": "off",
// TODO: There are many cases to refactor away,
// https://github.com/matrix-org/matrix-js-sdk/issues/2978
"jest/no-conditional-expect": "off",
// Also treat "oldBackendOnly" as a test function.
// Used in some crypto tests.
"jest/no-standalone-expect": [
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
)
)
steps:
- uses: tibdex/backport@v2
- uses: tibdex/backport@2e217641d82d02ba0603f46b1aeedefb258890ac # v2
with:
labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2.26.0
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
@@ -22,7 +22,7 @@ jobs:
path: docs
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v1
uses: matrix-org/netlify-pr-preview@v2
with:
path: docs
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@v2
uses: peter-evans/repository-dispatch@26b39ed245ab8f31526069329e112ab2fb224588 # v2
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+2 -2
View File
@@ -8,7 +8,7 @@ on:
secrets:
ELEMENT_BOT_TOKEN:
required: true
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
jobs:
changelog:
name: Preview Changelog
@@ -39,7 +39,7 @@ jobs:
if: github.event.action == 'opened'
steps:
- name: Check membership
uses: tspascoal/get-user-teams-membership@v2
uses: tspascoal/get-user-teams-membership@37c08f7b52a72ca95d12af2e7ab2553ca9adf13b # v2
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
+2 -2
View File
@@ -20,11 +20,11 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
run: "yarn install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@v1
uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
+21 -28
View File
@@ -11,46 +11,39 @@ jobs:
- name: 🧮 Checkout code
uses: actions/checkout@v3
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
path: _docs
- name: 🔧 Yarn cache
uses: actions/setup-node@v3
with:
cache: "yarn"
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
run: "yarn install --frozen-lockfile"
- name: 📖 Generate JSDoc
run: "yarn gendoc"
- name: 📋 Copy to temp
- name: 🔨 Install symlinks
run: |
cp -a "./_docs" "$RUNNER_TEMP/"
sudo apt-get update
sudo apt-get install -y symlinks
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
- name: 🔪 Prepare
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
- name: 📖 Generate docs
run: |
VERSION="${GITHUB_REF_NAME#v}"
[ ! -e "$VERSION" ] || rm -r $VERSION
cp -r $RUNNER_TEMP/_docs/ $VERSION
# Add the new directory to the index if it isn't there already
if ! grep -q ">Version $VERSION</a>" index.html; then
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
fi
yarn tpv purge --yes --out _docs --stale --major 10
yarn gendoc
symlinks -rc _docs
- name: 🚀 Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
publish_dir: .
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
git add . --all
git commit -m "Update docs"
git push
working-directory: _docs
npm:
name: Publish
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Cypress is done.
- uses: Sibz/github-status-action@v1
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -27,7 +27,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.3
uses: matrix-org/sonarcloud-workflow-action@v2.5
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
@@ -42,7 +42,7 @@ jobs:
coverage_extract_path: coverage
extra_args: ${{ inputs.extra_args }}
- uses: Sibz/github-status-action@v1
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+3 -1
View File
@@ -11,6 +11,7 @@ jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
runs-on: ubuntu-latest
outputs:
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
@@ -19,7 +20,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@v2
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
@@ -35,6 +36,7 @@ jobs:
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
needs: prepare
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
+8 -1
View File
@@ -65,7 +65,14 @@ jobs:
run: "yarn install"
- name: Generate Docs
run: "yarn run gendoc"
run: "yarn run gendoc --treatWarningsAsErrors"
# Upload artifact duplicates symlink contents so we do this to save 75% space
- name: Flatten symlink and write _redirects
run: |
find _docs -mindepth 1 -maxdepth 1 ! -type f ! -name stable -printf '/%f/* /stable/:splat\n' > _docs/_redirects
find _docs -mindepth 1 -maxdepth 1 -type l -delete
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
- name: Upload Artifact
uses: actions/upload-artifact@v3
+23 -11
View File
@@ -8,6 +8,8 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
ENABLE_COVERAGE: ${{ github.event_name != 'merge_group' }}
jobs:
jest:
name: "Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})"
@@ -36,26 +38,24 @@ jobs:
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: Load metrics reporter
id: metrics
if: github.ref == 'refs/heads/develop'
run: |
echo "extra-reporter='--reporters=<rootDir>/spec/slowReporter.js'" >> $GITHUB_OUTPUT
uses: SimenB/github-actions-cpu-cores@410541432439795d30db6501fb1d8178eb41e502 # v1
- name: Run tests
run: |
yarn coverage \
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--ci \
--reporters github-actions ${{ steps.metrics.outputs.extra-reporter }} \
--max-workers ${{ steps.cpu-cores.outputs.count }} \
./spec/${{ matrix.specs }}
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v3
with:
name: coverage
@@ -71,7 +71,8 @@ jobs:
disable_coverage: true
matrix-js-sdk-sha: ${{ github.sha }}
# Hook for branch protection to work outside merge queues
# Hook for branch protection to skip downstream testing outside of merge queues
# and skip sonarcloud coverage within merge queues
downstream:
name: Downstream tests
runs-on: ubuntu-latest
@@ -79,5 +80,16 @@ jobs:
needs:
- matrix-react-sdk
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
description: SonarCloud skipped
context: SonarCloud Code Analysis
sha: ${{ github.sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- if: needs.matrix-react-sdk.result != 'skipped' && needs.matrix-react-sdk.result != 'success'
run: exit 1
+5 -5
View File
@@ -20,7 +20,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/upgrade-deps
@@ -31,8 +31,8 @@ jobs:
T-Task
- name: Enable automerge
uses: peter-evans/enable-pull-request-automerge@v2
run: gh pr merge --merge --auto "$PR_NUMBER"
if: steps.cpr.outputs.pull-request-operation == 'created'
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
env:
GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
+101
View File
@@ -1,3 +1,104 @@
Changes in [26.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.1) (2023-06-09)
==================================================================================================
## 🐛 Bug Fixes
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
Changes in [26.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.0) (2023-06-06)
==================================================================================================
## 🚨 BREAKING CHANGES
* Ensure we do not add relations to the wrong timeline ([\#3427](https://github.com/matrix-org/matrix-js-sdk/pull/3427)). Fixes vector-im/element-web#25450 and vector-im/element-web#25494.
* Deprecate `QrCodeEvent`, `SasEvent` and `VerificationEvent` ([\#3386](https://github.com/matrix-org/matrix-js-sdk/pull/3386)).
## 🦖 Deprecations
* Move crypto classes into a separate namespace ([\#3385](https://github.com/matrix-org/matrix-js-sdk/pull/3385)).
## ✨ Features
* Mention deno support in the README ([\#3417](https://github.com/matrix-org/matrix-js-sdk/pull/3417)). Contributed by @sigmaSd.
* Mark room version 10 as safe ([\#3425](https://github.com/matrix-org/matrix-js-sdk/pull/3425)).
* Prioritise entirely supported flows for UIA ([\#3402](https://github.com/matrix-org/matrix-js-sdk/pull/3402)).
* Add methods to terminate idb worker ([\#3362](https://github.com/matrix-org/matrix-js-sdk/pull/3362)).
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
## 🐛 Bug Fixes
* Correctly accumulate sync summaries. ([\#3366](https://github.com/matrix-org/matrix-js-sdk/pull/3366)). Fixes vector-im/element-web#23345.
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
* Remove spec non-compliant extended glob format ([\#3423](https://github.com/matrix-org/matrix-js-sdk/pull/3423)). Fixes vector-im/element-web#25474.
* Fix bug where original event was inserted into timeline instead of the edit event ([\#3398](https://github.com/matrix-org/matrix-js-sdk/pull/3398)). Contributed by @andybalaam.
* Only add a local receipt if it's after an existing receipt ([\#3399](https://github.com/matrix-org/matrix-js-sdk/pull/3399)). Contributed by @andybalaam.
* Attempt a potential workaround for stuck notifs ([\#3384](https://github.com/matrix-org/matrix-js-sdk/pull/3384)). Fixes vector-im/element-web#25406. Contributed by @andybalaam.
* Fix verification bug with `pendingEventOrdering: "chronological"` ([\#3382](https://github.com/matrix-org/matrix-js-sdk/pull/3382)).
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
==================================================================================================
## 🐛 Bug Fixes
* Rebuild to fix packaging glitch in 25.1.0. Fixes #3363
Changes in [25.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.0) (2023-05-09)
==================================================================================================
## 🦖 Deprecations
* Deprecate MatrixClient::resolveRoomAlias ([\#3316](https://github.com/matrix-org/matrix-js-sdk/pull/3316)).
## ✨ Features
* add client method to remove pusher ([\#3324](https://github.com/matrix-org/matrix-js-sdk/pull/3324)). Contributed by @kerryarchibald.
* Implement MSC 3981 ([\#3248](https://github.com/matrix-org/matrix-js-sdk/pull/3248)). Fixes vector-im/element-web#25021. Contributed by @justjanne.
* Added `Room.getLastLiveEvent` and `Room.getLastThread`. Deprecated `Room.lastThread` in favour of `Room.getLastThread`. ([\#3321](https://github.com/matrix-org/matrix-js-sdk/pull/3321)).
* Element-R: wire up device lists ([\#3272](https://github.com/matrix-org/matrix-js-sdk/pull/3272)). Contributed by @florianduros.
* Node 20 support ([\#3302](https://github.com/matrix-org/matrix-js-sdk/pull/3302)).
## 🐛 Bug Fixes
* Fix racing between one-time-keys processing and sync ([\#3327](https://github.com/matrix-org/matrix-js-sdk/pull/3327)). Fixes vector-im/element-web#25214. Contributed by @florianduros.
* Fix lack of media when a user reconnects ([\#3318](https://github.com/matrix-org/matrix-js-sdk/pull/3318)).
* Fix TimelineWindow getEvents exploding if no neigbouring timeline ([\#3285](https://github.com/matrix-org/matrix-js-sdk/pull/3285)). Fixes vector-im/element-web#25104.
Changes in [25.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0) (2023-04-25)
==================================================================================================
## 🚨 BREAKING CHANGES
* Change `Store.save()` to return a `Promise` ([\#3221](https://github.com/matrix-org/matrix-js-sdk/pull/3221)). Contributed by @texuf.
## ✨ Features
* Add typedoc-plugin-mdn-links ([\#3292](https://github.com/matrix-org/matrix-js-sdk/pull/3292)).
* Annotate events with executed push rule ([\#3284](https://github.com/matrix-org/matrix-js-sdk/pull/3284)). Contributed by @kerryarchibald.
* Element-R: pass device list change notifications into rust crypto-sdk ([\#3254](https://github.com/matrix-org/matrix-js-sdk/pull/3254)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
* Support for MSC3882 revision 1 ([\#3228](https://github.com/matrix-org/matrix-js-sdk/pull/3228)). Contributed by @hughns.
## 🐛 Bug Fixes
* Fix screen sharing on Firefox 113 ([\#3282](https://github.com/matrix-org/matrix-js-sdk/pull/3282)). Contributed by @tulir.
* Retry processing potential poll events after decryption ([\#3246](https://github.com/matrix-org/matrix-js-sdk/pull/3246)). Fixes vector-im/element-web#24568.
* Element-R: handle events which arrive before their keys ([\#3230](https://github.com/matrix-org/matrix-js-sdk/pull/3230)). Fixes vector-im/element-web#24489.
Changes in [24.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.1.0) (2023-04-11)
==================================================================================================
## ✨ Features
* Allow via_servers property in findPredecessor (update to MSC3946) ([\#3240](https://github.com/matrix-org/matrix-js-sdk/pull/3240)). Contributed by @andybalaam.
* Fire `closed` event when IndexedDB closes unexpectedly ([\#3218](https://github.com/matrix-org/matrix-js-sdk/pull/3218)).
* Implement MSC3952: intentional mentions ([\#3092](https://github.com/matrix-org/matrix-js-sdk/pull/3092)). Fixes vector-im/element-web#24376.
* Send one time key count and unused fallback keys for rust-crypto ([\#3215](https://github.com/matrix-org/matrix-js-sdk/pull/3215)). Fixes vector-im/element-web#24795. Contributed by @florianduros.
* Improve `processBeaconEvents` hotpath ([\#3200](https://github.com/matrix-org/matrix-js-sdk/pull/3200)).
* Implement MSC3966: a push rule condition to check if an array contains a value ([\#3180](https://github.com/matrix-org/matrix-js-sdk/pull/3180)).
## 🐛 Bug Fixes
* indexddb-local-backend - return the current sync to database promise … ([\#3222](https://github.com/matrix-org/matrix-js-sdk/pull/3222)). Contributed by @texuf.
* Revert "Add the call object to Call events" ([\#3236](https://github.com/matrix-org/matrix-js-sdk/pull/3236)).
* Handle group call redaction ([\#3231](https://github.com/matrix-org/matrix-js-sdk/pull/3231)). Fixes vector-im/voip-internal#128.
* Stop doing O(n^2) work to find event's home (`eventShouldLiveIn`) ([\#3227](https://github.com/matrix-org/matrix-js-sdk/pull/3227)). Contributed by @jryans.
* Fix bug where video would not unmute if it started muted ([\#3213](https://github.com/matrix-org/matrix-js-sdk/pull/3213)). Fixes vector-im/element-call#925.
* Fixes to event encryption in the Rust Crypto implementation ([\#3202](https://github.com/matrix-org/matrix-js-sdk/pull/3202)).
Changes in [24.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.0.0) (2023-03-28)
==================================================================================================
## 🔒 Security
* Fixes for [CVE-2023-28427](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE-2023-28427) / GHSA-mwq8-fjpf-c2gr
Changes in [23.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.5.0) (2023-03-15)
==================================================================================================
+2
View File
@@ -55,6 +55,8 @@ client.publicRooms(function (err, data) {
See below for how to include libolm to enable end-to-end-encryption. Please check
[the Node.js terminal app](examples/node) for a more complex example.
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
To start the client:
```javascript
+39
View File
@@ -0,0 +1,39 @@
/* Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { Config } from "jest";
import { env } from "process";
const config: Config = {
testEnvironment: "node",
testMatch: ["<rootDir>/spec/**/*.spec.{js,ts}"],
setupFilesAfterEnv: ["<rootDir>/spec/setupTests.ts"],
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
};
// if we're running under GHA, enable the GHA reporter
if (env["GITHUB_ACTIONS"] !== undefined) {
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
// if we're running against the develop branch, also enable the slow test reporter
if (env["GITHUB_REF"] == "refs/heads/develop") {
reporters.push("<rootDir>/spec/slowReporter.js");
}
config.reporters = reporters;
}
export default config;
+16 -28
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "23.5.0",
"version": "26.0.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -55,13 +55,13 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"loglevel": "^1.7.1",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.0.0",
"matrix-widget-api": "^1.3.1",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
@@ -101,16 +101,16 @@
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.34.0",
"eslint": "8.40.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsdoc": "^40.0.0",
"eslint-plugin-jsdoc": "^44.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^45.0.0",
"eslint-plugin-unicorn": "^47.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fetch-mock-jest": "^1.5.1",
@@ -119,30 +119,18 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.4",
"rimraf": "^4.0.0",
"prettier": "2.8.8",
"rimraf": "^5.0.0",
"terser": "^5.5.1",
"ts-node": "^10.9.1",
"tsify": "^5.0.2",
"typedoc": "^0.23.20",
"typedoc-plugin-missing-exports": "^1.0.0",
"typescript": "^4.5.3"
},
"jest": {
"testEnvironment": "node",
"testMatch": [
"<rootDir>/spec/**/*.spec.{js,ts}"
],
"setupFilesAfterEnv": [
"<rootDir>/spec/setupTests.ts"
],
"collectCoverageFrom": [
"<rootDir>/src/**/*.{js,ts}"
],
"coverageReporters": [
"text-summary",
"lcov"
],
"testResultsProcessor": "@casualbot/jest-sonar-reporter"
"typedoc": "^0.24.0",
"typedoc-plugin-coverage": "^2.1.0",
"typedoc-plugin-mdn-links": "^3.0.3",
"typedoc-plugin-missing-exports": "^2.0.0",
"typedoc-plugin-versions": "^0.2.3",
"typedoc-plugin-versions-cli": "^0.1.12",
"typescript": "^5.0.0"
},
"@casualbot/jest-sonar-reporter": {
"outputDirectory": "coverage",
+2 -2
View File
@@ -130,7 +130,7 @@ fi
# global cache here to ensure we get the right thing.
yarn cache clean
# Ensure all dependencies are updated
yarn install --ignore-scripts --pure-lockfile
yarn install --ignore-scripts --frozen-lockfile
# ignore leading v on release
release="${1#v}"
@@ -225,7 +225,7 @@ if [ $dodist -eq 0 ]; then
pushd "$builddir"
git clone "$projdir" .
git checkout "$rel_branch"
yarn install --pure-lockfile
yarn install --frozen-lockfile
# We haven't tagged yet, so tell the dist script what version
# it's building
DIST_VERSION="$tag" yarn dist
+1 -1
View File
@@ -15,4 +15,4 @@ for line in sys.stdin:
break
found_first_header = True
elif not re.match(r"^=+$", line) and len(line) > 0:
print line
print(line)
+117
View File
@@ -0,0 +1,117 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
const TEST_USER_ID = "@alice:localhost";
const TEST_DEVICE_ID = "xzcvb";
/**
* Integration tests for cross-signing functionality.
*
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
let aliceClient: MatrixClient;
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
});
await initCrypto(aliceClient);
});
afterEach(async () => {
await aliceClient.stopClient();
fetchMock.mockReset();
});
describe("bootstrapCrossSigning (before initialsync completes)", () => {
it("publishes keys if none were yet published", async () => {
// have account_data requests return an empty object
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
// 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",
},
{},
);
// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
await makeRequest(authDict);
};
// now bootstrap cross signing, and check it resolves successfully
await aliceClient.bootstrapCrossSigning({
authUploadDeviceSigningKeys: uiaCallback,
});
// check the cross-signing keys upload
expect(fetchMock.called("upload-keys")).toBeTruthy();
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
const keysBody = JSON.parse(keysOpts!.body as string);
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
// there should be a key of each type
// master key is signed by the device
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
// ssk and usk are signed by the master key
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
// check the publish call
expect(fetchMock.called("upload-sigs")).toBeTruthy();
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
const body = JSON.parse(sigsOpts!.body as string);
// there should be a signature for our device, by our self-signing key.
expect(body).toHaveProperty(
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
);
});
});
});
+371 -47
View File
@@ -19,7 +19,7 @@ import anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { MockResponse } from "fetch-mock";
import { MockResponse, MockResponseFunction } from "fetch-mock";
import type { IDeviceKeys } from "../../src/@types/crypto";
import * as testUtils from "../test-utils/test-utils";
@@ -47,6 +47,9 @@ import {
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
import { escapeRegExp } from "../../src/utils";
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
import { flushPromises } from "../test-utils/flushPromises";
const ROOM_ID = "!room:id";
@@ -341,6 +344,11 @@ async function expectSendRoomKey(
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,
},
);
});
}
@@ -359,12 +367,20 @@ async function expectSendMegolmMessage(
inboundGroupSessionPromise: Promise<Olm.InboundGroupSession>,
): Promise<Partial<IEvent>> {
const encryptedMessageContent = await new Promise<IContent>((resolve) => {
fetchMock.putOnce(new RegExp("/send/m.room.encrypted/"), (url: string, opts: RequestInit): MockResponse => {
resolve(JSON.parse(opts.body as string));
return {
event_id: "$event_id",
};
});
fetchMock.putOnce(
new RegExp("/send/m.room.encrypted/"),
(url: string, opts: RequestInit): MockResponse => {
resolve(JSON.parse(opts.body as string));
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,
},
);
});
// In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now.
@@ -438,8 +454,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
return response;
}
const rootRegexp = escapeRegExp(new URL("/_matrix/client/", aliceClient.getHomeserverUrl()).toString());
fetchMock.postOnce(
new URL("/_matrix/client/r0/keys/query", aliceClient.getHomeserverUrl()).toString(),
new RegExp(rootRegexp + "(r0|v3)/keys/query"),
(url: string, opts: RequestInit) => onQueryRequest(JSON.parse(opts.body as string)),
{
// append to the list of intercepts on this path
@@ -448,6 +465,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
);
}
/**
* Add an expectation for a /keys/claim request for the MatrixClient under test
*
* @param response - the response to return from the request. Normally an {@link IClaimOTKsResult}
* (or a function that returns one).
*/
function expectAliceKeyClaim(response: MockResponse | MockResponseFunction) {
const rootRegexp = escapeRegExp(new URL("/_matrix/client/", aliceClient.getHomeserverUrl()).toString());
fetchMock.postOnce(new RegExp(rootRegexp + "(r0|v3)/keys/claim"), response);
}
/**
* Get the device keys for testOlmAccount in a format suitable for a
* response to /keys/query
@@ -536,6 +564,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
fetchMock.mockReset();
});
it("MatrixClient.getCrypto returns a CryptoApi", () => {
expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices");
});
it("Alice receives a megolm message", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -543,7 +575,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -594,16 +626,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(decryptedEvent.getContent().body).toEqual("42");
});
oldBackendOnly("Alice receives a megolm message before the session keys", async () => {
it("Alice receives a megolm message before the session keys", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
// https://github.com/vector-im/element-web/issues/2273
await startClientAndAwaitFirstSync();
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -637,7 +667,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
expect(room.getLiveTimeline().getEvents()[0].getContent().msgtype).toEqual("m.bad.encrypted");
const event = room.getLiveTimeline().getEvents()[0];
// wait for a first attempt at decryption: should fail
await testUtils.awaitDecryption(event);
expect(event.getContent().msgtype).toEqual("m.bad.encrypted");
// now she gets the room_key event
syncResponder.sendOrQueueSyncResponse({
@@ -648,20 +682,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
await syncPromise(aliceClient);
const event = room.getLiveTimeline().getEvents()[0];
let decryptedEvent: MatrixEvent;
if (event.getContent().msgtype != "m.bad.encrypted") {
decryptedEvent = event;
} else {
decryptedEvent = await new Promise<MatrixEvent>((resolve) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
}
expect(decryptedEvent.getContent().body).toEqual("42");
await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.getContent().body).toEqual("42");
});
it("Alice gets a second room_key message", async () => {
@@ -671,7 +693,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -738,7 +760,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(event.getContent().body).toEqual("42");
});
oldBackendOnly("prepareToEncrypt", async () => {
it("prepareToEncrypt", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
aliceClient.setGlobalErrorOnUnknownDevices(false);
@@ -751,10 +773,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// ... and then claim one of his OTKs
fetchMock.postOnce(
new URL("/_matrix/client/r0/keys/claim", aliceClient.getHomeserverUrl()).toString(),
getTestKeysClaimResponse("@bob:xyz"),
);
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// fire off the prepare request
const room = aliceClient.getRoom(ROOM_ID);
@@ -768,7 +787,71 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await p;
});
it("Alice sends a megolm message with GlobalErrorOnUnknownDevices=false", async () => {
aliceClient.setGlobalErrorOnUnknownDevices(false);
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
// Once we send the message, Alice will check Bob's device list (twice, because reasons) ...
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// ... and claim one of his OTKs ...
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// ... and send an m.room_key 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.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
]);
});
it("We should start a new megolm session after forceDiscardSession", async () => {
aliceClient.setGlobalErrorOnUnknownDevices(false);
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
// Once we send the message, Alice will check Bob's device list (twice, because reasons) ...
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz"));
// ... and claim one of his OTKs ...
expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz"));
// ... and send an m.room_key message
const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount);
// Send the first message, and check we can decrypt it.
await Promise.all([
aliceClient.sendTextMessage(ROOM_ID, "test"),
expectSendMegolmMessage(inboundGroupSessionPromise),
]);
// Finally the interesting part: discard the session.
aliceClient.forceDiscardSession(ROOM_ID);
// Now when we send the next message, we should get a *new* megolm session.
const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount);
const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2);
await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]);
});
oldBackendOnly("Alice sends a megolm message", async () => {
// TODO: do something about this for the rust backend.
// Currently it fails because we don't respect the default GlobalErrorOnUnknownDevices and
// send messages to unknown devices.
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
@@ -1027,20 +1110,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
throw new Error("sendTextMessage succeeded on an unknown device");
} catch (e) {
expect((e as any).name).toEqual("UnknownDeviceError");
expect(Object.keys((e as any).devices)).toEqual([aliceClient.getUserId()!]);
expect(Object.keys((e as any)?.devices[aliceClient.getUserId()!])).toEqual(["DEVICE_ID"]);
expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]);
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID")).toBeTruthy();
}
// mark the device as known, and resend.
aliceClient.setDeviceKnown(aliceClient.getUserId()!, "DEVICE_ID");
fetchMock.postOnce(
new URL("/_matrix/client/r0/keys/claim", aliceClient.getHomeserverUrl()).toString(),
(url: string, opts: RequestInit): MockResponse => {
const content = JSON.parse(opts.body as string);
expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519");
return getTestKeysClaimResponse(aliceClient.getUserId()!);
},
);
expectAliceKeyClaim((url: string, opts: RequestInit): MockResponse => {
const content = JSON.parse(opts.body as string);
expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519");
return getTestKeysClaimResponse(aliceClient.getUserId()!);
});
const inboundGroupSessionPromise = expectSendRoomKey(aliceClient.getUserId()!, testOlmAccount);
@@ -1099,7 +1179,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -1255,7 +1335,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz";
}
@@ -1322,7 +1402,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// if we're using the old crypto impl, stub out some methods in the device manager.
// TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic.
if (aliceClient.crypto) {
aliceClient.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
aliceClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
aliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device;
aliceClient.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!;
}
@@ -1849,4 +1929,248 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(event.getContent().body).not.toContain("withheld");
});
});
describe("key upload request", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
const keysCount = Object.keys(content?.one_time_keys || {}).length;
const fallbackKeysCount = Object.keys(content?.fallback_keys || {}).length;
if (keysCount) resolve({ keysCount, fallbackKeysCount });
return {
one_time_key_counts: {
// The matrix client does `/upload` requests until 50 keys are uploaded
// We return here 60 to avoid the `/upload` request loop
signed_curve25519: keysCount ? 60 : keysCount,
},
};
};
for (const path of ["/_matrix/client/r0/keys/upload", "/_matrix/client/v3/keys/upload"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener, {
// These routes are already defined in the E2EKeyReceiver
// We want to overwrite the behaviour of the E2EKeyReceiver
overwriteRoutes: true,
});
}
});
}
it("should make key upload request after sync", async () => {
let uploadPromise = awaitKeyUploadRequest();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
syncResponder.sendOrQueueSyncResponse(getSyncResponse([]));
await syncPromise(aliceClient);
// Verify that `/upload` is called on Alice's homesever
const { keysCount, fallbackKeysCount } = await uploadPromise;
expect(keysCount).toBeGreaterThan(0);
expect(fallbackKeysCount).toBe(0);
uploadPromise = awaitKeyUploadRequest();
syncResponder.sendOrQueueSyncResponse({
next_batch: 2,
device_one_time_keys_count: { signed_curve25519: 0 },
device_unused_fallback_key_types: [],
});
// Advance local date to 2 minutes
// The old crypto only runs the upload every 60 seconds
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
await syncPromise(aliceClient);
// After we set device_one_time_keys_count to 0
// a `/upload` is expected
const res = await uploadPromise;
expect(res.keysCount).toBeGreaterThan(0);
expect(res.fallbackKeysCount).toBeGreaterThan(0);
});
});
describe("getUserDeviceInfo", () => {
afterEach(() => {
jest.useRealTimers();
});
// From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery
// Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification
const queryResponseBody = {
device_keys: {
"@testing_florian1:matrix.org": {
EBMMPAFOPU: {
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "EBMMPAFOPU",
keys: {
"curve25519:EBMMPAFOPU": "HyhQD4mXwNViqns0noABW9NxHbCAOkriQ4QKGGndk3w",
"ed25519:EBMMPAFOPU": "xSQaxrFOTXH+7Zjo+iwb445hlNPFjnx1O3KaV3Am55k",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:EBMMPAFOPU":
"XFJVq9HmO5lfJN7l6muaUt887aUHg0/poR3p9XHGXBrLUqzfG7Qllq7jjtUjtcTc5CMD7/mpsXfuC2eV+X1uAw",
},
},
user_id: "@testing_florian1:matrix.org",
unsigned: {
device_display_name: "display name",
},
},
},
},
failures: {},
master_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["master"],
keys: {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:UKAQMJSJZC":
"q4GuzzuhZfTpwrlqnJ9+AEUtEfEQ0um1PO3puwp/+vidzFicw0xEPjedpJoASYQIJ8XJAAWX8Q235EKeCzEXCA",
},
},
},
},
self_signing_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["self_signing"],
keys: {
"ed25519:YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo":
"YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"yckmxgQ3JA5bb205/RunJipnpZ37ycGNf4OFzDwAad++chd71aGHqAMQ1f6D2GVfl8XdHmiRaohZf4mGnDL0AA",
},
},
},
},
user_signing_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["user_signing"],
keys: {
"ed25519:Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs":
"Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"WxNNXb13yCrBwXUQzdDWDvWSQ/qWCfwpvssOudlAgbtMzRESMbCTDkeA8sS1awaAtUmu7FrPtDb5LYfK/EE2CQ",
},
},
},
},
};
function awaitKeyQueryRequest(): Promise<Record<string, []>> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
// Resolve with request payload
resolve(content.device_keys);
// Return response of `/keys/query`
return queryResponseBody;
};
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
}
});
}
it("Download uncached keys for known user", async () => {
const queryPromise = awaitKeyQueryRequest();
const user = "@testing_florian1:matrix.org";
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
// Wait for `/keys/query` to be called
const deviceKeysPayload = await queryPromise;
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
expect(devicesInfo.get(user)?.size).toBe(1);
// Convert the expected device to IDevice and check
expect(devicesInfo.get(user)?.get("EBMMPAFOPU")).toStrictEqual(
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]?.EBMMPAFOPU),
);
});
it("Download uncached keys for unknown user", async () => {
const queryPromise = awaitKeyQueryRequest();
const user = "@bob:xyz";
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
// Wait for `/keys/query` to be called
const deviceKeysPayload = await queryPromise;
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
// The old crypto has an empty map for `@bob:xyz`
// The new crypto does not have the `@bob:xyz` entry in `devicesInfo`
expect(devicesInfo.get(user)?.size).toBeFalsy();
});
it("Get devices from tacked users", async () => {
jest.useFakeTimers();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const queryPromise = awaitKeyQueryRequest();
const user = "@testing_florian1:matrix.org";
// `user` will be added to the room
syncResponder.sendOrQueueSyncResponse(getSyncResponse([user, "@bob:xyz"]));
// Advance local date to 2 minutes
// The old crypto only runs the upload every 60 seconds
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
await syncPromise(aliceClient);
// Old crypto: for alice: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
jest.runAllTimers();
// Old crypto: for alice: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
await flushPromises();
// Wait for alice to query `user` keys
await queryPromise;
// Old crypto: for `user`: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
jest.runAllTimers();
// Old crypto: for `user`: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
// It will add `@testing_florian1:matrix.org` devices to the DeviceList
await flushPromises();
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user]);
// We should only have the `user` in it
expect(devicesInfo.size).toBe(1);
// We are expecting only the EBMMPAFOPU device
expect(devicesInfo.get(user)!.size).toBe(1);
expect(devicesInfo.get(user)!.get("EBMMPAFOPU")).toEqual(
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]["EBMMPAFOPU"]),
);
});
});
});
+121 -9
View File
@@ -21,11 +21,13 @@ import {
EventStatus,
EventTimeline,
EventTimelineSet,
EventType,
Filter,
IEvent,
MatrixClient,
MatrixEvent,
PendingEventOrdering,
RelationType,
Room,
} from "../../src/matrix";
import { logger } from "../../src/logger";
@@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util
import { TestClient } from "../TestClient";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { emitPromise } from "../test-utils/test-utils";
import { Feature, ServerSupport } from "../../src/feature";
const userId = "@alice:localhost";
const userName = "Alice";
@@ -1139,7 +1142,7 @@ describe("MatrixClient event timelines", function () {
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
room.addLiveEvents([THREAD_REPLY2]);
await room.addLiveEvents([THREAD_REPLY2]);
httpBackend
.when(
"GET",
@@ -1153,7 +1156,118 @@ describe("MatrixClient event timelines", function () {
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
room.addLiveEvents([THREAD_REPLY3]);
await room.addLiveEvents([THREAD_REPLY3]);
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_REPLY3.getId(),
]);
});
it("should ensure thread events don't get reordered with recursive relations", async () => {
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY2.localTimestamp += 1000;
const THREAD_ROOT_REACTION = utils.mkEvent({
event: true,
type: EventType.Reaction,
user: userId,
room: roomId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: THREAD_ROOT.event_id!,
key: Math.random().toString(),
},
},
});
THREAD_ROOT_REACTION.localTimestamp += 2000;
// Test data for a second reply to the first thread
const THREAD_REPLY3 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 3",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY3.localTimestamp += 3000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 3,
latest_event: THREAD_REPLY3.event,
},
},
},
};
// @ts-ignore
client.clientOpts.threadSupport = true;
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
await room.addLiveEvents([THREAD_REPLY2]);
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({
dir: Direction.Backward,
limit: 3,
recurse: true,
}),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
await room.addLiveEvents([THREAD_REPLY3]);
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
@@ -1458,7 +1572,7 @@ describe("MatrixClient event timelines", function () {
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD2_ROOT);
room.addLiveEvents([THREAD_REPLY2]);
await room.addLiveEvents([THREAD_REPLY2]);
await httpBackend.flushAllExpected();
await prom;
expect(thread.length).toBe(2);
@@ -1823,11 +1937,6 @@ describe("MatrixClient event timelines", function () {
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
@@ -1847,7 +1956,10 @@ describe("MatrixClient event timelines", function () {
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }),
buildRelationPaginationQuery({
dir: Direction.Backward,
from: "start_token",
}),
)
.respond(200, function () {
return {
+72 -5
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import HttpBackend from "matrix-mock-request";
import { Mocked } from "jest-mock";
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
@@ -24,6 +25,7 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { IFilterDefinition } from "../../src/filter";
import { ISearchResults } from "../../src/@types/search";
import { IStore } from "../../src/store";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
describe("MatrixClient", function () {
const userId = "@alice:localhost";
@@ -603,14 +605,14 @@ describe("MatrixClient", function () {
});
const prom = client!.downloadKeys(["boris", "chaz"]).then(function (res) {
assertObjectContains(res.boris.dev1, {
assertObjectContains(res.get("boris")!.get("dev1")!, {
verified: 0, // DeviceVerification.UNVERIFIED
keys: { "ed25519:dev1": ed25519key },
algorithms: ["1"],
unsigned: { abc: "def" },
});
assertObjectContains(res.chaz.dev2, {
assertObjectContains(res.get("chaz")!.get("dev2")!, {
verified: 0, // DeviceVerification.UNVERIFIED
keys: { "ed25519:dev2": ed25519key },
algorithms: ["2"],
@@ -1127,22 +1129,51 @@ describe("MatrixClient", function () {
describe("requestLoginToken", () => {
it("should hit the expected API endpoint with UIA", async () => {
httpBackend!
.when("GET", "/capabilities")
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
const response = {};
const uiaData = {};
const prom = client!.requestLoginToken(uiaData);
httpBackend!
.when("POST", "/unstable/org.matrix.msc3882/login/token", { auth: uiaData })
.when("POST", "/unstable/org.matrix.msc3882/login/get_token", { auth: uiaData })
.respond(200, response);
await httpBackend!.flush("");
expect(await prom).toStrictEqual(response);
});
it("should hit the expected API endpoint without UIA", async () => {
const response = {};
httpBackend!
.when("GET", "/capabilities")
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: true } } });
const response = { login_token: "xyz", expires_in_ms: 5000 };
const prom = client!.requestLoginToken();
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
await httpBackend!.flush("");
// check that expires_in has been populated for compatibility with r0
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
});
it("should hit the r1 endpoint when capability is disabled", async () => {
httpBackend!
.when("GET", "/capabilities")
.respond(200, { capabilities: { "org.matrix.msc3882.get_login_token": { enabled: false } } });
const response = { login_token: "xyz", expires_in_ms: 5000 };
const prom = client!.requestLoginToken();
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/get_token", {}).respond(200, response);
await httpBackend!.flush("");
// check that expires_in has been populated for compatibility with r0
expect(await prom).toStrictEqual({ ...response, expires_in: 5 });
});
it("should hit the r0 endpoint for fallback", async () => {
httpBackend!.when("GET", "/capabilities").respond(200, {});
const response = { login_token: "xyz", expires_in: 5 };
const prom = client!.requestLoginToken();
httpBackend!.when("POST", "/unstable/org.matrix.msc3882/login/token", {}).respond(200, response);
await httpBackend!.flush("");
expect(await prom).toStrictEqual(response);
// check that expires_in has been populated for compatibility with r1
expect(await prom).toStrictEqual({ ...response, expires_in_ms: 5000 });
});
});
@@ -1383,6 +1414,42 @@ describe("MatrixClient", function () {
await client!.uploadKeys();
});
});
describe("getCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client!.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
getTrustCrossSignedDevices: jest.fn().mockReturnValue(true),
} as unknown as Mocked<CryptoBackend>;
client!["cryptoBackend"] = mockBackend;
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(true);
mockBackend.getTrustCrossSignedDevices.mockReturnValue(false);
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(false);
});
});
describe("setCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client!.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
setTrustCrossSignedDevices: jest.fn(),
} as unknown as Mocked<CryptoBackend>;
client!["cryptoBackend"] = mockBackend;
client!.setCryptoTrustCrossSignedDevices(true);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true);
client!.setCryptoTrustCrossSignedDevices(false);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false);
});
});
});
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
+4 -8
View File
@@ -157,14 +157,10 @@ describe("MatrixClient opts", function () {
error: "Ruh roh",
}),
);
try {
await Promise.all([
expect(client.sendTextMessage("!foo:bar", "a body", "txn1")).rejects.toThrow(),
httpBackend.flush("/txn1", 1),
]);
} catch (err) {
expect((<MatrixError>err).errcode).toEqual("M_SOMETHING");
}
await expect(
Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]),
).rejects.toThrow("MatrixError: [500] Unknown message");
});
it("shouldn't queue events", async () => {
+124
View File
@@ -36,11 +36,15 @@ import {
NotificationCountType,
IEphemeral,
Room,
IndexedDBStore,
RelationType,
} from "../../src";
import { ReceiptType } from "../../src/@types/read_receipts";
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
describe("MatrixClient syncing", () => {
const selfUserId = "@alice:localhost";
@@ -1867,4 +1871,124 @@ describe("MatrixClient syncing (IndexedDB version)", () => {
idbClient.stopClient();
idbHttpBackend.stop();
});
it("should query server for which thread a 2nd order relation belongs to and stash in sync accumulator", async () => {
const roomId = "!room:example.org";
async function startClient(client: MatrixClient): Promise<void> {
await Promise.all([
idbClient.startClient({
// Without this all events just go into the main timeline
threadSupport: true,
}),
idbHttpBackend.flushAllExpected(),
emitPromise(idbClient, ClientEvent.Room),
]);
}
function assertEventsExpected(client: MatrixClient): void {
const room = client.getRoom(roomId);
const mainTimelineEvents = room!.getLiveTimeline().getEvents();
expect(mainTimelineEvents).toHaveLength(1);
expect(mainTimelineEvents[0].getContent().body).toEqual("Test");
const thread = room!.getThread("$someThreadId")!;
expect(thread.replayEvents).toHaveLength(1);
expect(thread.replayEvents![0].getRelation()!.key).toEqual("🪿");
}
let idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
store: new IndexedDBStore({
indexedDB: global.indexedDB,
dbName: "test",
}),
});
let idbHttpBackend = idbTestClient.httpBackend;
let idbClient = idbTestClient.client;
await idbClient.store.startup();
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
const syncRoomSection = {
join: {
[roomId]: {
timeline: {
prev_batch: "foo",
events: [
mkMessage({
room: roomId,
user: selfUserId,
msg: "Test",
}),
mkEvent({
room: roomId,
user: selfUserId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: "$someUnknownEvent",
key: "🪿",
},
},
type: "m.reaction",
}),
],
},
},
},
};
idbHttpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: syncRoomSection,
});
idbHttpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/event/%24someUnknownEvent`).respond(
200,
mkEvent({
room: roomId,
user: selfUserId,
content: {
"body": "Thread response",
"m.relates_to": {
rel_type: THREAD_RELATION_TYPE.name,
event_id: "$someThreadId",
},
},
type: "m.room.message",
}),
);
await startClient(idbClient);
assertEventsExpected(idbClient);
idbHttpBackend.verifyNoOutstandingExpectation();
// Force sync accumulator to persist, reset client, assert it doesn't re-fetch event on next start-up
await idbClient.store.save(true);
await idbClient.stopClient();
await idbClient.store.destroy();
await idbHttpBackend.stop();
idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
store: new IndexedDBStore({
indexedDB: global.indexedDB,
dbName: "test",
}),
});
idbHttpBackend = idbTestClient.httpBackend;
idbClient = idbTestClient.client;
await idbClient.store.startup();
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
idbHttpBackend.when("GET", "/sync").respond(200, syncData);
await startClient(idbClient);
assertEventsExpected(idbClient);
idbHttpBackend.verifyNoOutstandingExpectation();
await idbClient.stopClient();
await idbHttpBackend.stop();
});
});
@@ -89,7 +89,7 @@ describe("MatrixClient syncing", () => {
const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] });
const threadReply = thread.events.at(-1)!;
room.addLiveEvents([thread.rootEvent]);
await room.addLiveEvents([thread.rootEvent]);
// Initialize read receipt datastructure before testing the reaction
room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false);
+3 -3
View File
@@ -472,7 +472,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -483,7 +483,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
@@ -545,7 +545,7 @@ describe("MatrixClient crypto", () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
+83 -108
View File
@@ -42,6 +42,7 @@ import { SyncApiOptions, SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client";
import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils";
import { defer } from "../../src/utils";
describe("SlidingSyncSdk", () => {
let client: MatrixClient | undefined;
@@ -301,67 +302,57 @@ describe("SlidingSyncSdk", () => {
},
};
it("can be created with required_state and timeline", () => {
it("can be created with required_state and timeline", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomA].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomA].name);
expect(gotRoom!.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
});
it("can be created with timeline only", () => {
it("can be created with timeline only", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomB].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomB].name);
expect(gotRoom!.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
});
it("can be created with a highlight_count", () => {
it("can be created with a highlight_count", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomC);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
data[roomC].highlight_count,
);
});
it("can be created with a notification_count", () => {
it("can be created with a notification_count", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomD);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
data[roomD].notification_count,
);
});
it("can be created with an invited/joined_count", () => {
it("can be created with an invited/joined_count", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomG);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
});
it("can be created with live events", () => {
let seenLiveEvent = false;
it("can be created with live events", async () => {
const seenLiveEventDeferred = defer<boolean>();
const listener = (
ev: MatrixEvent,
room?: Room,
@@ -371,43 +362,37 @@ describe("SlidingSyncSdk", () => {
) => {
if (timelineData?.liveEvent) {
assertTimelineEvents([ev], data[roomH].timeline.slice(-1));
seenLiveEvent = true;
seenLiveEventDeferred.resolve(true);
}
};
client!.on(RoomEvent.Timeline, listener);
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]);
await emitPromise(client!, ClientEvent.Room);
client!.off(RoomEvent.Timeline, listener);
const gotRoom = client!.getRoom(roomH);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomH].name);
expect(gotRoom.getMyMembership()).toEqual("join");
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomH].name);
expect(gotRoom!.getMyMembership()).toEqual("join");
// check the entire timeline is correct
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline);
expect(seenLiveEvent).toBe(true);
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline);
await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy();
});
it("can be created with invite_state", () => {
it("can be created with invite_state", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomE);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getMyMembership()).toEqual("invite");
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getMyMembership()).toEqual("invite");
expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite);
});
it("uses the 'name' field to caluclate the room name", () => {
it("uses the 'name' field to caluclate the room name", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomF);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomF].name);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomF].name);
});
describe("updating", () => {
@@ -419,33 +404,33 @@ describe("SlidingSyncSdk", () => {
name: data[roomA].name,
});
const gotRoom = client!.getRoom(roomA);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
const newTimeline = data[roomA].timeline;
newTimeline.push(newEvent);
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline);
});
it("can update with a new required_state event", async () => {
let gotRoom = client!.getRoom(roomB);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, {
required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")],
timeline: [],
name: data[roomB].name,
});
gotRoom = client!.getRoom(roomB);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted);
});
it("can update with a new highlight_count", async () => {
@@ -456,11 +441,11 @@ describe("SlidingSyncSdk", () => {
highlight_count: 1,
});
const gotRoom = client!.getRoom(roomC);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
});
it("can update with a new notification_count", async () => {
@@ -471,11 +456,11 @@ describe("SlidingSyncSdk", () => {
notification_count: 1,
});
const gotRoom = client!.getRoom(roomD);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
});
it("can update with a new joined_count", () => {
@@ -486,11 +471,11 @@ describe("SlidingSyncSdk", () => {
joined_count: 1,
});
const gotRoom = client!.getRoom(roomG);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
expect(gotRoom!.getJoinedMemberCount()).toEqual(1);
});
// Regression test for a bug which caused the timeline entries to be out-of-order
@@ -512,7 +497,7 @@ describe("SlidingSyncSdk", () => {
initial: true, // e.g requested via room subscription
});
const gotRoom = client!.getRoom(roomA);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
@@ -530,7 +515,7 @@ describe("SlidingSyncSdk", () => {
);
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline);
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline);
});
});
});
@@ -626,9 +611,9 @@ describe("SlidingSyncSdk", () => {
await httpBackend!.flush("/profile", 1, 1000);
await emitPromise(client!, RoomMemberEvent.Name);
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
const inviteeMember = room.getMember(invitee)!;
expect(inviteeMember).toBeDefined();
expect(inviteeMember).toBeTruthy();
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
});
@@ -662,41 +647,30 @@ describe("SlidingSyncSdk", () => {
});
it("can update device lists", () => {
client!.crypto!.processDeviceLists = jest.fn();
ext.onResponse({
device_lists: {
changed: ["@alice:localhost"],
left: ["@bob:localhost"],
},
});
// TODO: more assertions?
expect(client!.crypto!.processDeviceLists).toHaveBeenCalledWith({
changed: ["@alice:localhost"],
left: ["@bob:localhost"],
});
});
it("can update OTK counts", () => {
client!.crypto!.updateOneTimeKeyCount = jest.fn();
it("can update OTK counts and unused fallback keys", () => {
client!.crypto!.processKeyCounts = jest.fn();
ext.onResponse({
device_one_time_keys_count: {
signed_curve25519: 42,
},
});
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(42);
ext.onResponse({
device_one_time_keys_count: {
not_signed_curve25519: 42,
// missing field -> default to 0
},
});
expect(client!.crypto!.updateOneTimeKeyCount).toHaveBeenCalledWith(0);
});
it("can update fallback keys", () => {
ext.onResponse({
device_unused_fallback_key_types: ["signed_curve25519"],
});
expect(client!.crypto!.getNeedsNewFallback()).toEqual(false);
ext.onResponse({
device_unused_fallback_key_types: ["not_signed_curve25519"],
});
expect(client!.crypto!.getNeedsNewFallback()).toEqual(true);
expect(client!.crypto!.processKeyCounts).toHaveBeenCalledWith({ signed_curve25519: 42 }, [
"signed_curve25519",
]);
});
});
@@ -734,7 +708,7 @@ describe("SlidingSyncSdk", () => {
],
});
globalData = client!.getAccountData(globalType)!;
expect(globalData).toBeDefined();
expect(globalData).toBeTruthy();
expect(globalData.getContent()).toEqual(globalContent);
});
@@ -755,6 +729,7 @@ describe("SlidingSyncSdk", () => {
foo: "bar",
};
const roomType = "test";
await emitPromise(client!, ClientEvent.Room);
ext.onResponse({
rooms: {
[roomId]: [
@@ -766,9 +741,9 @@ describe("SlidingSyncSdk", () => {
},
});
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
const event = room.getAccountData(roomType)!;
expect(event).toBeDefined();
expect(event).toBeTruthy();
expect(event.getContent()).toEqual(roomContent);
});
@@ -891,11 +866,9 @@ describe("SlidingSyncSdk", () => {
const evType = ev.getType();
expect(seen[evType]).toBeFalsy();
seen[evType] = true;
if (evType === "m.key.verification.start" || evType === "m.key.verification.request") {
expect(ev.isCancelled()).toEqual(true);
} else {
expect(ev.isCancelled()).toEqual(false);
}
expect(ev.isCancelled()).toEqual(
evType === "m.key.verification.start" || evType === "m.key.verification.request",
);
});
ext.onResponse({
next_batch: "45678",
@@ -956,8 +929,9 @@ describe("SlidingSyncSdk", () => {
],
initial: true,
});
await emitPromise(client!, ClientEvent.Room);
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
expect(room.getMember(selfUserId)?.typing).toEqual(false);
ext.onResponse({
rooms: {
@@ -997,7 +971,7 @@ describe("SlidingSyncSdk", () => {
initial: true,
});
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
expect(room.getMember(selfUserId)?.typing).toEqual(false);
ext.onResponse({
rooms: {
@@ -1090,12 +1064,13 @@ describe("SlidingSyncSdk", () => {
],
initial: true,
});
await emitPromise(client!, ClientEvent.Room);
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
expect(room.getReadReceiptForUserId(alice, true)).toBeNull();
ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567));
const receipt = room.getReadReceiptForUserId(alice);
expect(receipt).toBeDefined();
expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(lastEvent.event_id);
expect(receipt?.data.ts).toEqual(1234567);
expect(receipt?.data.thread_id).toBeFalsy();
+1 -1
View File
@@ -1698,7 +1698,7 @@ describe("SlidingSync", () => {
});
function timeout(delayMs: number, reason: string): { promise: Promise<never>; cancel: () => void } {
let timeoutId: NodeJS.Timeout;
let timeoutId: ReturnType<typeof setTimeout>;
return {
promise: new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
+1 -1
View File
@@ -23,5 +23,5 @@ try {
global.Olm = require("@matrix-org/olm");
logger.log("loaded libolm");
} catch (e) {
logger.warn("unable to run crypto tests: libolm not available");
logger.warn("unable to run crypto tests: libolm not available", e);
}
+1 -1
View File
@@ -16,7 +16,7 @@ limitations under the License.
import DOMException from "domexception";
global.DOMException = DOMException;
global.DOMException = DOMException as typeof global.DOMException;
jest.mock("../src/http-api/utils", () => ({
...jest.requireActual("../src/http-api/utils"),
+76 -5
View File
@@ -6,7 +6,7 @@ import "../olm-loader";
import { logger } from "../../src/logger";
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType, RelationType } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
@@ -258,6 +258,9 @@ export interface IMessageOpts {
* @param opts.user - The user ID for the event.
* @param opts.msg - Optional. The content.body for the event.
* @param opts.event - True to make a MatrixEvent.
* @param opts.relatesTo - An IEventRelation relating this to another event.
* @param opts.ts - The timestamp of the event.
* @param opts.event - True to make a MatrixEvent.
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
* @returns The event
*/
@@ -297,6 +300,7 @@ interface IReplyMessageOpts extends IMessageOpts {
* @param opts.room - The room ID for the event.
* @param opts.user - The user ID for the event.
* @param opts.msg - Optional. The content.body for the event.
* @param opts.ts - The timestamp of the event.
* @param opts.replyToMessage - The replied message
* @param opts.event - True to make a MatrixEvent.
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
@@ -330,6 +334,73 @@ export function mkReplyMessage(
return mkEvent(eventOpts, client);
}
/**
* Create a reaction event.
*
* @param target - the event we are reacting to.
* @param client - the MatrixClient
* @param userId - the userId of the sender
* @param roomId - the id of the room we are in
* @param ts - The timestamp of the event.
* @returns The event
*/
export function mkReaction(
target: MatrixEvent,
client: MatrixClient,
userId: string,
roomId: string,
ts?: number,
): MatrixEvent {
return mkEvent(
{
event: true,
type: EventType.Reaction,
user: userId,
room: roomId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: target.getId()!,
key: Math.random().toString(),
},
},
ts,
},
client,
);
}
export function mkEdit(
target: MatrixEvent,
client: MatrixClient,
userId: string,
roomId: string,
msg?: string,
ts?: number,
) {
msg = msg ?? `Edit of ${target.getId()}`;
return mkEvent(
{
event: true,
type: EventType.RoomMessage,
user: userId,
room: roomId,
content: {
"body": `* ${msg}`,
"m.new_content": {
body: msg,
},
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: target.getId()!,
},
},
ts,
},
client,
);
}
/**
* A mock implementation of webstorage
*/
@@ -375,17 +446,17 @@ export async function awaitDecryption(
// already
if (event.getClearContent() !== null) {
if (waitOnDecryptionFailure && event.isDecryptionFailure()) {
logger.log(`${Date.now()} event ${event.getId()} got decryption error; waiting`);
logger.log(`${Date.now()}: event ${event.getId()} got decryption error; waiting`);
} else {
return event;
}
} else {
logger.log(`${Date.now()} event ${event.getId()} is not yet decrypted; waiting`);
logger.log(`${Date.now()}: event ${event.getId()} is not yet decrypted; waiting`);
}
return new Promise((resolve) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
event.once(MatrixEventEvent.Decrypted, (ev, err) => {
logger.log(`${Date.now()}: MatrixEventEvent.Decrypted for event ${event.getId()}: ${err ?? "success"}`);
resolve(ev);
});
});
+21 -1
View File
@@ -115,6 +115,26 @@ type MakeThreadProps = {
ts?: number;
};
type MakeThreadResult = {
/**
* Thread model
*/
thread: Thread;
/**
* Thread root event
*/
rootEvent: MatrixEvent;
/**
* Events added to the thread
*/
events: MatrixEvent[];
};
/**
* Starts a new thread in a room by creating a message as thread root.
* Also creates a Thread model and adds it to the room.
* Does not insert the messages into a timeline.
*/
export const mkThread = ({
room,
client,
@@ -122,7 +142,7 @@ export const mkThread = ({
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => {
}: MakeThreadProps): MakeThreadResult => {
const { rootEvent, events } = makeThreadEvents({
roomId: room.roomId,
authorId,
+147 -6
View File
@@ -30,6 +30,7 @@ import {
RoomState,
RoomStateEvent,
RoomStateEventHandlerMap,
SendToDeviceContentMap,
} from "../../src";
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
import { ReEmitter } from "../../src/ReEmitter";
@@ -122,6 +123,7 @@ export class MockRTCPeerConnection {
public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void;
public iceConnectionStateChangeListener?: () => void;
public onTrackListener?: (e: RTCTrackEvent) => void;
public onDataChannelListener?: (ev: RTCDataChannelEvent) => void;
public needsNegotiation = false;
public readyToNegotiate: Promise<void>;
private onReadyToNegotiate?: () => void;
@@ -167,6 +169,8 @@ export class MockRTCPeerConnection {
this.iceConnectionStateChangeListener = listener;
} else if (type == "track") {
this.onTrackListener = listener;
} else if (type == "datachannel") {
this.onDataChannelListener = listener;
}
}
public createDataChannel(label: string, opts: RTCDataChannelInit) {
@@ -231,6 +235,12 @@ export class MockRTCPeerConnection {
this.negotiationNeededListener();
}
}
public triggerIncomingDataChannel(): void {
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
}
public restartIce(): void {}
}
export class MockRTCRtpSender {
@@ -443,11 +453,7 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
>();
public sendToDevice = jest.fn<
Promise<{}>,
[
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
]
[eventType: string, contentMap: SendToDeviceContentMap, txnId?: string]
>();
public isInitialSyncComplete(): boolean {
@@ -502,18 +508,22 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
public state = CallState.Ringing;
public opponentUserId = FAKE_USER_ID_1;
public opponentDeviceId = FAKE_DEVICE_ID_1;
public opponentSessionId = FAKE_SESSION_ID_1;
public opponentMember = { userId: this.opponentUserId };
public callId = "1";
public localUsermediaFeed = {
setAudioVideoMuted: jest.fn<void, [boolean, boolean]>(),
isAudioMuted: jest.fn().mockReturnValue(false),
isVideoMuted: jest.fn().mockReturnValue(false),
stream: new MockMediaStream("stream"),
};
} as unknown as CallFeed;
public remoteUsermediaFeed?: CallFeed;
public remoteScreensharingFeed?: CallFeed;
public reject = jest.fn<void, []>();
public answerWithCallFeeds = jest.fn<void, [CallFeed[]]>();
public hangup = jest.fn<void, []>();
public initStats = jest.fn<void, []>();
public sendMetadataUpdate = jest.fn<void, []>();
@@ -525,6 +535,14 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
return this.opponentDeviceId;
}
public getOpponentSessionId(): string | undefined {
return this.opponentSessionId;
}
public getLocalFeeds(): CallFeed[] {
return [this.localUsermediaFeed];
}
public typed(): MatrixCall {
return this as unknown as MatrixCall;
}
@@ -585,6 +603,7 @@ export function makeMockGroupCallStateEvent(
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
},
redacted?: boolean,
): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix),
@@ -592,6 +611,7 @@ export function makeMockGroupCallStateEvent(
getTs: jest.fn().mockReturnValue(0),
getContent: jest.fn().mockReturnValue(content),
getStateKey: jest.fn().mockReturnValue(groupCallId),
isRedacted: jest.fn().mockReturnValue(redacted ?? false),
} as unknown as MatrixEvent;
}
@@ -604,3 +624,124 @@ export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: s
getStateKey: jest.fn().mockReturnValue(groupCallId),
} as unknown as MatrixEvent;
}
export const REMOTE_SFU_DESCRIPTION =
"v=0\n" +
"o=- 3242942315779688438 1678878001 IN IP4 0.0.0.0\n" +
"s=-\n" +
"t=0 0\n" +
"a=fingerprint:sha-256 EA:30:B2:7F:49:B5:46:D6:40:72:BF:79:95:C1:65:08:6E:35:09:FB:90:89:DA:EF:6B:82:D1:38:8C:25:39:B2\n" +
"a=group:BUNDLE 0 1 2\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111 9 0 8\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:0\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:111 opus/48000/2\n" +
"a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1\n" +
"a=rtcp-fb:111 transport-cc \n" +
"a=rtpmap:9 G722/8000\n" +
"a=rtpmap:0 PCMU/8000\n" +
"a=rtpmap:8 PCMA/8000\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=ssrc:2963372119 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:2963372119 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=ssrc:2963372119 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:2963372119 label:4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 4b811ab6-6926-473d-8ca5-ac45f268c507\n" +
"a=sendrecv\n" +
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 41385 typ host\n" +
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 41385 typ host\n" +
"a=candidate:1155505470 1 udp 2130706431 13.41.173.213 40026 typ host\n" +
"a=candidate:1155505470 2 udp 2130706431 13.41.173.213 40026 typ host\n" +
"a=end-of-candidates\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 104 106 108 109 98 99 112 116\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:1\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:96 VP8/90000\n" +
"a=rtcp-fb:96 goog-remb \n" +
"a=rtcp-fb:96 transport-cc \n" +
"a=rtcp-fb:96 ccm fir\n" +
"a=rtcp-fb:96 nack \n" +
"a=rtcp-fb:96 nack pli\n" +
"a=rtpmap:97 rtx/90000\n" +
"a=fmtp:97 apt=96\n" +
"a=rtpmap:102 H264/90000\n" +
"a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\n" +
"a=rtcp-fb:102 goog-remb \n" +
"a=rtcp-fb:102 transport-cc \n" +
"a=rtcp-fb:102 ccm fir\n" +
"a=rtcp-fb:102 nack \n" +
"a=rtcp-fb:102 nack pli\n" +
"a=rtpmap:103 rtx/90000\n" +
"a=fmtp:103 apt=102\n" +
"a=rtpmap:104 H264/90000\n" +
"a=fmtp:104 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\n" +
"a=rtcp-fb:104 goog-remb \n" +
"a=rtcp-fb:104 transport-cc \n" +
"a=rtcp-fb:104 ccm fir\n" +
"a=rtcp-fb:104 nack \n" +
"a=rtcp-fb:104 nack pli\n" +
"a=rtpmap:106 H264/90000\n" +
"a=fmtp:106 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
"a=rtcp-fb:106 goog-remb \n" +
"a=rtcp-fb:106 transport-cc \n" +
"a=rtcp-fb:106 ccm fir\n" +
"a=rtcp-fb:106 nack \n" +
"a=rtcp-fb:106 nack pli\n" +
"a=rtpmap:108 H264/90000\n" +
"a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\n" +
"a=rtcp-fb:108 goog-remb \n" +
"a=rtcp-fb:108 transport-cc \n" +
"a=rtcp-fb:108 ccm fir\n" +
"a=rtcp-fb:108 nack \n" +
"a=rtcp-fb:108 nack pli\n" +
"a=rtpmap:109 rtx/90000\n" +
"a=fmtp:109 apt=108\n" +
"a=rtpmap:98 VP9/90000\n" +
"a=fmtp:98 profile-id=0\n" +
"a=rtcp-fb:98 goog-remb \n" +
"a=rtcp-fb:98 transport-cc \n" +
"a=rtcp-fb:98 ccm fir\n" +
"a=rtcp-fb:98 nack \n" +
"a=rtcp-fb:98 nack pli\n" +
"a=rtpmap:99 rtx/90000\n" +
"a=fmtp:99 apt=98\n" +
"a=rtpmap:112 H264/90000\n" +
"a=fmtp:112 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f\n" +
"a=rtcp-fb:112 goog-remb \n" +
"a=rtcp-fb:112 transport-cc \n" +
"a=rtcp-fb:112 ccm fir\n" +
"a=rtcp-fb:112 nack \n" +
"a=rtcp-fb:112 nack pli\n" +
"a=rtpmap:116 ulpfec/90000\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
"a=rid:f recv\n" +
"a=rid:h recv\n" +
"a=rid:q recv\n" +
"a=simulcast:recv f;h;q\n" +
"a=ssrc:1212931603 cname:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:1212931603 msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=ssrc:1212931603 mslabel:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90\n" +
"a=ssrc:1212931603 label:12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=msid:dcc3a6d5-37a1-42e7-94a9-d520f20d0c90 12905f48-75b9-499f-ba50-fc00f56a86c6\n" +
"a=sendrecv\n" +
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\n" +
"c=IN IP4 0.0.0.0\n" +
"a=setup:actpass\n" +
"a=mid:2\n" +
"a=sendrecv\n" +
"a=sctp-port:5000\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs";
+2 -14
View File
@@ -46,13 +46,7 @@ describe("NamespacedValue", () => {
});
it("should not permit falsey values for both parts", () => {
try {
new UnstableValue(null!, null!);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<Error>e).message).toBe("One of stable or unstable values must be supplied");
}
expect(() => new UnstableValue(null!, null!)).toThrow("One of stable or unstable values must be supplied");
});
});
@@ -72,12 +66,6 @@ describe("UnstableValue", () => {
});
it("should not permit falsey unstable values", () => {
try {
new UnstableValue("stable", null!);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<Error>e).message).toBe("Unstable value must be supplied");
}
expect(() => new UnstableValue("stable", null!)).toThrow("Unstable value must be supplied");
});
});
+7 -33
View File
@@ -381,12 +381,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -405,7 +400,7 @@ describe("Crypto", function () {
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventsPromise;
@@ -617,12 +612,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await secondAliceClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -725,12 +715,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -805,12 +790,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -897,12 +877,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -1011,7 +986,6 @@ describe("Crypto", function () {
jest.setTimeout(10000);
const client = new TestClient("@a:example.com", "dev").client;
await client.initCrypto();
client.crypto!.getSecretStorageKey = jest.fn().mockResolvedValue(null);
client.crypto!.isCrossSigningReady = async () => false;
client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
@@ -1039,7 +1013,7 @@ describe("Crypto", function () {
beforeEach(async () => {
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
ensureOlmSessionsForDevices.mockResolvedValue({});
ensureOlmSessionsForDevices.mockResolvedValue(new Map());
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => {
result.plaintext = { type: 0, body: JSON.stringify(payload) };
+6 -5
View File
@@ -102,9 +102,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () {
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache });
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0);
expect(getCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
if (shouldCache) {
expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
// eslint-disable-next-line jest/no-conditional-expect
expect(getCrossSigningKeyCache).toHaveBeenLastCalledWith(type, expect.any(String));
}
},
);
@@ -115,10 +116,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () {
const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache });
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
expect(storeCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0);
if (shouldCache) {
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
// eslint-disable-next-line jest/no-conditional-expect
expect(storeCrossSigningKeyCache).toHaveBeenLastCalledWith(type, testKey);
}
});
+58 -44
View File
@@ -34,6 +34,7 @@ import { ClientEvent, MatrixClient, RoomMember } from "../../../../src";
import { DeviceInfo, IDevice } from "../../../../src/crypto/deviceinfo";
import { DeviceTrustLevel } from "../../../../src/crypto/CrossSigning";
import { MegolmEncryption as MegolmEncryptionClass } from "../../../../src/crypto/algorithms/megolm";
import { recursiveMapToObject } from "../../../../src/utils";
import { sleep } from "../../../../src/utils";
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get("m.megolm.v1.aes-sha2")!;
@@ -183,14 +184,22 @@ describe("MegolmDecryption", function () {
const deviceInfo = {} as DeviceInfo;
mockCrypto.getStoredDevice.mockReturnValue(deviceInfo);
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({
"@alice:foo": {
alidevice: {
sessionId: "alisession",
device: new DeviceInfo("alidevice"),
},
},
});
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue(
new Map([
[
"@alice:foo",
new Map([
[
"alidevice",
{
sessionId: "alisession",
device: new DeviceInfo("alidevice"),
},
],
]),
],
]),
);
const awaitEncryptForDevice = new Promise<void>((res, rej) => {
mockOlmLib.encryptMessageForDevice.mockImplementation(() => {
@@ -357,11 +366,7 @@ describe("MegolmDecryption", function () {
} as unknown as DeviceInfo;
mockCrypto.downloadKeys.mockReturnValue(
Promise.resolve({
"@alice:home.server": {
aliceDevice: aliceDeviceInfo,
},
}),
Promise.resolve(new Map([["@alice:home.server", new Map([["aliceDevice", aliceDeviceInfo]])]])),
);
mockCrypto.checkDeviceTrust.mockReturnValue({
@@ -523,23 +528,32 @@ describe("MegolmDecryption", function () {
let megolm: MegolmEncryptionClass;
let room: jest.Mocked<Room>;
const deviceMap: DeviceInfoMap = {
"user-a": {
"device-a": new DeviceInfo("device-a"),
"device-b": new DeviceInfo("device-b"),
"device-c": new DeviceInfo("device-c"),
},
"user-b": {
"device-d": new DeviceInfo("device-d"),
"device-e": new DeviceInfo("device-e"),
"device-f": new DeviceInfo("device-f"),
},
"user-c": {
"device-g": new DeviceInfo("device-g"),
"device-h": new DeviceInfo("device-h"),
"device-i": new DeviceInfo("device-i"),
},
};
const deviceMap: DeviceInfoMap = new Map([
[
"user-a",
new Map([
["device-a", new DeviceInfo("device-a")],
["device-b", new DeviceInfo("device-b")],
["device-c", new DeviceInfo("device-c")],
]),
],
[
"user-b",
new Map([
["device-d", new DeviceInfo("device-d")],
["device-e", new DeviceInfo("device-e")],
["device-f", new DeviceInfo("device-f")],
]),
],
[
"user-c",
new Map([
["device-g", new DeviceInfo("device-g")],
["device-h", new DeviceInfo("device-h")],
["device-i", new DeviceInfo("device-i")],
]),
],
]);
beforeEach(() => {
room = testUtils.mock(Room, "Room") as jest.Mocked<Room>;
@@ -572,8 +586,8 @@ describe("MegolmDecryption", function () {
//@ts-ignore private member access, gross
await megolm.encryptionPreparation?.promise;
for (const userId in deviceMap) {
for (const deviceId in deviceMap[userId]) {
for (const [userId, devices] of deviceMap) {
for (const deviceId of devices.keys()) {
expect(mockCrypto.checkDeviceTrust).toHaveBeenCalledWith(userId, deviceId);
}
}
@@ -658,20 +672,20 @@ describe("MegolmDecryption", function () {
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"].bobdevice1.session_id;
delete contentMap["@bob:example.com"].bobdevice1["org.matrix.msgid"];
delete contentMap["@bob:example.com"].bobdevice2.session_id;
delete contentMap["@bob:example.com"].bobdevice2["org.matrix.msgid"];
expect(contentMap).toStrictEqual({
"@bob:example.com": {
bobdevice1: {
delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["session_id"];
delete contentMap.get("@bob:example.com")?.get("bobdevice1")?.["org.matrix.msgid"];
delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["session_id"];
delete contentMap.get("@bob:example.com")?.get("bobdevice2")?.["org.matrix.msgid"];
expect(recursiveMapToObject(contentMap)).toStrictEqual({
["@bob:example.com"]: {
["bobdevice1"]: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: "m.unverified",
reason: "The sender has disabled encrypting to unverified devices.",
sender_key: aliceDevice.deviceCurve25519Key,
},
bobdevice2: {
["bobdevice2"]: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: "m.blacklisted",
@@ -839,10 +853,10 @@ describe("MegolmDecryption", function () {
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"]["bobdevice"]["org.matrix.msgid"];
expect(contentMap).toStrictEqual({
"@bob:example.com": {
bobdevice: {
delete contentMap.get("@bob:example.com")?.get("bobdevice")?.["org.matrix.msgid"];
expect(recursiveMapToObject(contentMap)).toStrictEqual({
["@bob:example.com"]: {
["bobdevice"]: {
algorithm: "m.megolm.v1.aes-sha2",
code: "m.no_olm",
reason: "Unable to establish a secure channel.",
+15 -16
View File
@@ -146,18 +146,21 @@ describe("OlmDevice", function () {
});
},
} as unknown as MockedObject<MatrixClient>;
const devicesByUser = {
"@bob:example.com": [
DeviceInfo.fromStorage(
{
keys: {
"curve25519:ABCDEFG": "akey",
const devicesByUser = new Map([
[
"@bob:example.com",
[
DeviceInfo.fromStorage(
{
keys: {
"curve25519:ABCDEFG": "akey",
},
},
},
"ABCDEFG",
),
"ABCDEFG",
),
],
],
};
]);
// start two tasks that try to ensure that there's an olm session
const promises = Promise.all([
@@ -218,12 +221,8 @@ describe("OlmDevice", function () {
// There's no required ordering of devices per user, so here we
// create two different orderings so that each task reserves a
// device the other task needs before continuing.
const devicesByUserAB = {
"@bob:example.com": [deviceBobA, deviceBobB],
};
const devicesByUserBA = {
"@bob:example.com": [deviceBobB, deviceBobA],
};
const devicesByUserAB = new Map([["@bob:example.com", [deviceBobA, deviceBobB]]]);
const devicesByUserBA = new Map([["@bob:example.com", [deviceBobB, deviceBobA]]]);
const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices(aliceOlmDevice, baseApis, devicesByUserAB));
+2
View File
@@ -456,6 +456,7 @@ describe("MegolmBackup", function () {
client.http.authedRequest = function (method, path, queryParams, data, opts): any {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
/* eslint-disable jest/no-conditional-expect */
if (numCalls === 1) {
expect(method).toBe("POST");
expect(path).toBe("/room_keys/version");
@@ -482,6 +483,7 @@ describe("MegolmBackup", function () {
reject(new Error("authedRequest called too many times"));
return Promise.resolve({});
}
/* eslint-enable jest/no-conditional-expect */
};
}),
client.createKeyBackupVersion({
+3 -2
View File
@@ -24,10 +24,11 @@ import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixError } from "../../../src/http-api";
import { logger } from "../../../src/logger";
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto";
import { CryptoEvent } from "../../../src/crypto";
import { IDevice } from "../../../src/crypto/deviceinfo";
import { TestClient } from "../../TestClient";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
const PUSH_RULES_RESPONSE: Response = {
method: "GET",
@@ -146,7 +147,7 @@ describe("Cross Signing", function () {
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
await func({});
};
+58
View File
@@ -0,0 +1,58 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { DeviceVerification } from "../../../src";
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for DeviceInfo initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const verified = DeviceVerification.Verified;
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("deviceInfoToDevice", () => {
it("should convert a DeviceInfo to a Device", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(verified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.signatures.size).toBe(0);
});
});
});
+9 -19
View File
@@ -17,7 +17,6 @@ limitations under the License.
import "../../olm-loader";
import * as olmlib from "../../../src/crypto/olmlib";
import { IObject } from "../../../src/crypto/olmlib";
import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/crypto/SecretStorage";
import { MatrixEvent } from "../../../src/models/event";
import { TestClient } from "../../TestClient";
import { makeTestClients } from "./verification/util";
@@ -25,10 +24,10 @@ import { encryptAES } from "../../../src/crypto/aes";
import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils";
import { logger } from "../../../src/logger";
import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client";
import { ISecretStorageKeyInfo } from "../../../src/crypto/api";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { ISignatures } from "../../../src/@types/signed";
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
async function makeTestClient(
userInfo: { userId: string; deviceId: string },
@@ -45,7 +44,7 @@ async function makeTestClient(
await client.initCrypto();
// No need to download keys for these tests
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue({});
jest.spyOn(client.crypto!, "downloadKeys").mockResolvedValue(new Map());
return client;
}
@@ -149,22 +148,14 @@ describe("Secrets", function () {
it("should throw if given a key that doesn't exist", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
try {
await alice.storeSecret("foo", "bar", ["this secret does not exist"]);
// should be able to use expect(...).toThrow() but mocha still fails
// the test even when it throws for reasons I have no inclination to debug
expect(true).toBeFalsy();
} catch (e) {}
await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy();
alice.stopClient();
});
it("should refuse to encrypt with zero keys", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
try {
await alice.storeSecret("foo", "bar", []);
expect(true).toBeFalsy();
} catch (e) {}
await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy();
alice.stopClient();
});
@@ -215,10 +206,7 @@ describe("Secrets", function () {
it("should refuse to encrypt if no keys given and no default key", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
try {
await alice.storeSecret("foo", "bar");
expect(true).toBeFalsy();
} catch (e) {}
await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy();
alice.stopClient();
});
@@ -274,7 +262,7 @@ describe("Secrets", function () {
Object.values(otks)[0],
);
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({});
osborne2.client.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map());
osborne2.client.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com";
const request = await secretStorage.request("foo", ["VAX"]);
@@ -541,7 +529,9 @@ describe("Secrets", function () {
await alice.bootstrapSecretStorage({});
expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()).toEqual({ key: "key_id" });
const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")!.getContent<ISecretStorageKeyInfo>();
const keyInfo = alice
.getAccountData("m.secret_storage.key.key_id")!
.getContent<SecretStorageKeyDescription>();
expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2");
expect(keyInfo.passphrase).toEqual({
algorithm: "m.pbkdf2",
+43 -15
View File
@@ -121,12 +121,12 @@ describe("SAS verification", function () {
alice.client.crypto!.deviceList.storeDevicesForUser("@bob:example.com", BOB_DEVICES);
alice.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
bob.client.crypto!.deviceList.storeDevicesForUser("@alice:example.com", ALICE_DEVICES);
bob.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
aliceSasEvent = null;
@@ -144,7 +144,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
@@ -169,13 +169,14 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
}
});
});
afterEach(async () => {
await Promise.all([alice.stop(), bob.stop()]);
@@ -186,10 +187,14 @@ describe("SAS verification", function () {
let macMethod;
let keyAgreement;
const origSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = function (type, map) {
bob.client.sendToDevice = async (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
keyAgreement = map[alice.client.getUserId()!][alice.client.deviceId!].key_agreement_protocol;
macMethod = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.message_authentication_code;
keyAgreement = map
.get(alice.client.getUserId()!)
?.get(alice.client.deviceId!)?.key_agreement_protocol;
}
return origSendToDevice(type, map);
};
@@ -237,7 +242,7 @@ describe("SAS verification", function () {
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = [
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
"hkdf-hmac-sha256",
];
}
@@ -246,7 +251,9 @@ describe("SAS verification", function () {
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
macMethod = map
.get(alice.client.getUserId()!)!
.get(alice.client.deviceId!)!.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
@@ -291,14 +298,18 @@ describe("SAS verification", function () {
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map[bob.client.getUserId()!][bob.client.deviceId!].message_authentication_codes = ["hmac-sha256"];
map.get(bob.client.getUserId()!)!.get(bob.client.deviceId!)!.message_authentication_codes = [
"hmac-sha256",
];
}
return aliceOrigSendToDevice(type, map);
};
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()!][alice.client.deviceId!].message_authentication_code;
macMethod = map
.get(alice.client.getUserId()!)!
.get(alice.client.deviceId!)!.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
@@ -363,6 +374,12 @@ describe("SAS verification", function () {
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const bobDeviceVerificationStatus = (await alice.client
.getCrypto()!
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
expect(aliceTrust.isTofu()).toBeTruthy();
@@ -370,6 +387,17 @@ describe("SAS verification", function () {
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const aliceDeviceVerificationStatus = (await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
const unknownDeviceVerificationStatus = await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "xyz");
expect(unknownDeviceVerificationStatus).toBe(null);
});
});
@@ -454,7 +482,7 @@ describe("SAS verification", function () {
);
};
alice.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
bob.client.crypto!.setDeviceVerification = jest.fn();
@@ -472,7 +500,7 @@ describe("SAS verification", function () {
return "bob+base64+ed25519+key";
};
bob.client.downloadKeys = () => {
return Promise.resolve({});
return Promise.resolve(new Map());
};
aliceSasEvent = null;
@@ -491,7 +519,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
@@ -515,7 +543,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
+5 -5
View File
@@ -20,7 +20,7 @@ import { IContent, MatrixEvent } from "../../../../src/models/event";
import { IRoomTimelineData } from "../../../../src/models/event-timeline-set";
import { Room, RoomEvent } from "../../../../src/models/room";
import { logger } from "../../../../src/logger";
import { MatrixClient, ClientEvent, ICreateClientOpts } from "../../../../src/client";
import { MatrixClient, ClientEvent, ICreateClientOpts, SendToDeviceContentMap } from "../../../../src/client";
interface UserInfo {
userId: string;
@@ -36,16 +36,16 @@ export async function makeTestClients(
const clientMap: Record<string, Record<string, MatrixClient>> = {};
const makeSendToDevice =
(matrixClient: MatrixClient): MatrixClient["sendToDevice"] =>
async (type, map) => {
async (type: string, contentMap: SendToDeviceContentMap) => {
// logger.log(this.getUserId(), "sends", type, map);
for (const [userId, devMap] of Object.entries(map)) {
for (const [userId, deviceMessages] of contentMap) {
if (userId in clientMap) {
for (const [deviceId, msg] of Object.entries(devMap)) {
for (const [deviceId, message] of deviceMessages) {
if (deviceId in clientMap[userId]) {
const event = new MatrixEvent({
sender: matrixClient.getUserId()!,
type: type,
content: msg,
content: message,
});
const client = clientMap[userId][deviceId];
const decryptionPromise = event.isEncrypted()
@@ -25,6 +25,7 @@ import { IContent, MatrixEvent } from "../../../../src/models/event";
import { MatrixClient } from "../../../../src/client";
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
import { MapWithDefault } from "../../../../src/utils";
type MockClient = MatrixClient & {
popEvents: () => MatrixEvent[];
@@ -33,7 +34,9 @@ type MockClient = MatrixClient & {
function makeMockClient(userId: string, deviceId: string): MockClient {
let counter = 1;
let events: MatrixEvent[] = [];
const deviceEvents: Record<string, Record<string, MatrixEvent[]>> = {};
const deviceEvents: MapWithDefault<string, MapWithDefault<string, MatrixEvent[]>> = new MapWithDefault(
() => new MapWithDefault(() => []),
);
return {
getUserId() {
return userId;
@@ -58,15 +61,11 @@ function makeMockClient(userId: string, deviceId: string): MockClient {
return Promise.resolve({ event_id: eventId });
},
sendToDevice(type: string, msgMap: Record<string, Record<string, IContent>>) {
for (const userId of Object.keys(msgMap)) {
const deviceMap = msgMap[userId];
for (const deviceId of Object.keys(deviceMap)) {
const content = deviceMap[deviceId];
sendToDevice(type: string, msgMap: Map<string, Map<string, IContent>>) {
for (const [userId, deviceMessages] of msgMap) {
for (const [deviceId, content] of deviceMessages) {
const event = new MatrixEvent({ content, type });
deviceEvents[userId] = deviceEvents[userId] || {};
deviceEvents[userId][deviceId] = deviceEvents[userId][deviceId] || [];
deviceEvents[userId][deviceId].push(event);
deviceEvents.getOrCreate(userId).getOrCreate(deviceId).push(event);
}
}
return Promise.resolve({});
@@ -79,14 +78,9 @@ function makeMockClient(userId: string, deviceId: string): MockClient {
return e;
},
// @ts-ignore special testing fn
popDeviceEvents(userId: string, deviceId: string): MatrixEvent[] {
const forDevice = deviceEvents[userId];
const events = forDevice && forDevice[deviceId];
const result = events || [];
if (events) {
delete forDevice[deviceId];
}
const result = deviceEvents.get(userId)?.get(deviceId) || [];
deviceEvents?.get(userId)?.delete(deviceId);
return result;
},
} as unknown as MockClient;
+10 -5
View File
@@ -204,9 +204,14 @@ describe("RoomWidgetClient", () => {
});
describe("to-device messages", () => {
const unencryptedContentMap = {
"@alice:example.org": { "*": { hello: "alice!" } },
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
const unencryptedContentMap = new Map([
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
["@bob:example.org", new Map([["bobDesktop", { hello: "bob!" }]])],
]);
const expectedRequestData = {
["@alice:example.org"]: { ["*"]: { hello: "alice!" } },
["@bob:example.org"]: { ["bobDesktop"]: { hello: "bob!" } },
};
it("sends unencrypted (sendToDevice)", async () => {
@@ -214,7 +219,7 @@ describe("RoomWidgetClient", () => {
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
await client.sendToDevice("org.example.foo", unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
});
it("sends unencrypted (queueToDevice)", async () => {
@@ -229,7 +234,7 @@ describe("RoomWidgetClient", () => {
],
};
await client.queueToDevice(batch);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
});
it("sends encrypted (encryptAndSendToDevices)", async () => {
+2 -86
View File
@@ -24,13 +24,10 @@ import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
RelationType,
Room,
RoomEvent,
} from "../../src";
import { FeatureSupport, Thread } from "../../src/models/thread";
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
import { eventMapperFor } from "../../src/event-mapper";
describe("EventTimelineSet", () => {
const roomId = "!foo:bar";
@@ -82,6 +79,7 @@ describe("EventTimelineSet", () => {
beforeEach(() => {
client = utils.mock(MatrixClient, "MatrixClient");
client.reEmitter = utils.mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
room = new Room(roomId, client, userA);
eventTimelineSet = new EventTimelineSet(room);
eventTimeline = new EventTimeline(eventTimelineSet);
@@ -205,88 +203,6 @@ describe("EventTimelineSet", () => {
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
Thread.hasServerSideSupport = FeatureSupport.Stable;
const sender = "@alice:matrix.org";
const root = utils.mkEvent({
event: true,
content: {
body: "Thread root",
},
type: EventType.RoomMessage,
sender,
});
room.addLiveEvents([root]);
const threadReply = utils.mkEvent({
event: true,
content: {
"body": "Thread reply",
"m.relates_to": {
event_id: root.getId()!,
rel_type: RelationType.Thread,
},
},
type: EventType.RoomMessage,
sender,
});
root.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
count: 1,
latest_event: {
content: threadReply.getContent(),
origin_server_ts: 5,
room_id: room.roomId,
sender,
type: EventType.RoomMessage,
event_id: threadReply.getId()!,
user_id: sender,
age: 1,
},
current_user_participated: true,
},
},
});
const editToThreadReply = utils.mkEvent({
event: true,
content: {
"body": " * edit",
"m.new_content": {
"body": "edit",
"msgtype": "m.text",
"org.matrix.msc1767.text": "edit",
},
"m.relates_to": {
event_id: threadReply.getId()!,
rel_type: RelationType.Replace,
},
},
type: EventType.RoomMessage,
sender,
});
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
return true;
});
jest.spyOn(client, "relations").mockResolvedValue({
events: [],
});
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
thread.once(RoomEvent.TimelineReset, () => {
const lastEvent = thread.timeline.at(-1)!;
expect(lastEvent.getContent().body).toBe(" * edit");
});
});
describe("non-room timeline", () => {
it("Adds event to timeline", () => {
const nonRoomEventTimelineSet = new EventTimelineSet(
+55
View File
@@ -18,6 +18,7 @@ import { FetchHttpApi } from "../../../src/http-api/fetch";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { QueryDict } from "../../../src/utils";
describe("FetchHttpApi", () => {
const baseUrl = "http://baseUrl";
@@ -235,4 +236,58 @@ describe("FetchHttpApi", () => {
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
});
});
describe("getUrl()", () => {
const localBaseUrl = "http://baseurl";
const baseUrlWithTrailingSlash = "http://baseurl/";
const makeApi = (thisBaseUrl = baseUrl): FetchHttpApi<any> => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn });
};
type TestParams = {
path: string;
queryParams?: QueryDict;
prefix?: string;
baseUrl?: string;
};
type TestCase = [TestParams, string];
const queryParams: QueryDict = {
test1: 99,
test2: ["a", "b"],
};
const testPrefix = "/just/testing";
const testUrl = "http://justtesting.com";
const testUrlWithTrailingSlash = "http://justtesting.com/";
const testCases: TestCase[] = [
[{ path: "/terms" }, `${localBaseUrl}${prefix}/terms`],
[{ path: "/terms", queryParams }, `${localBaseUrl}${prefix}/terms?test1=99&test2=a&test2=b`],
[{ path: "/terms", prefix: testPrefix }, `${localBaseUrl}${testPrefix}/terms`],
[{ path: "/terms", baseUrl: testUrl }, `${testUrl}${prefix}/terms`],
[{ path: "/terms", baseUrl: testUrlWithTrailingSlash }, `${testUrl}${prefix}/terms`],
[
{ path: "/terms", queryParams, prefix: testPrefix, baseUrl: testUrl },
`${testUrl}${testPrefix}/terms?test1=99&test2=a&test2=b`,
],
];
const runTests = (fetchBaseUrl: string) => {
it.each<TestCase>(testCases)(
"creates url with params %s",
({ path, queryParams, prefix, baseUrl }, result) => {
const api = makeApi(fetchBaseUrl);
expect(api.getUrl(path, queryParams, prefix, baseUrl)).toEqual(new URL(result));
},
);
};
describe("when fetch.opts.baseUrl does not have a trailing slash", () => {
runTests(localBaseUrl);
});
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
runTests(baseUrlWithTrailingSlash);
});
});
});
-3
View File
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import DOMException from "domexception";
import { mocked } from "jest-mock";
import { ClientPrefix, MatrixHttpApi, Method, UploadResponse } from "../../../src";
@@ -33,8 +32,6 @@ describe("MatrixHttpApi", () => {
const DONE = 0;
global.DOMException = DOMException;
beforeEach(() => {
xhr = {
upload: {} as XMLHttpRequestUpload,
+43
View File
@@ -517,4 +517,47 @@ describe("InteractiveAuth", () => {
expect(ia.getEmailSid()).toEqual(sid);
});
});
it("should prioritise shorter flows", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
requestEmailToken: jest.fn(),
authData: {
session: "sessionId",
flows: [{ stages: [AuthType.Recaptcha, AuthType.Password] }, { stages: [AuthType.Password] }],
params: {},
},
});
// @ts-ignore
ia.chooseStage();
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
});
it("should prioritise flows with entirely supported stages", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
requestEmailToken: jest.fn(),
authData: {
session: "sessionId",
flows: [{ stages: ["com.devture.shared_secret_auth"] }, { stages: [AuthType.Password] }],
params: {},
},
supportedStages: [AuthType.Password],
});
// @ts-ignore
ia.chooseStage();
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
});
});
+313 -1
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { Mocked, mocked } from "jest-mock";
import { logger } from "../../src/logger";
import { ClientEvent, IMatrixClientCreateOpts, ITurnServerResponse, MatrixClient, Store } from "../../src/client";
@@ -50,6 +50,12 @@ import {
MatrixScheduler,
Method,
Room,
EventTimelineSet,
PushRuleActionName,
TweakName,
RuleId,
IPushRule,
ConditionKind,
} from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
@@ -63,6 +69,8 @@ import { QueryDict } from "../../src/utils";
import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
jest.useFakeTimers();
@@ -725,6 +733,7 @@ describe("MatrixClient", function () {
getMyMembership: () => "join",
currentState: {
getStateEvents: (eventType, stateKey) => {
/* eslint-disable jest/no-conditional-expect */
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
@@ -743,6 +752,7 @@ describe("MatrixClient", function () {
} else {
throw new Error("Unexpected event type or state key");
}
/* eslint-enable jest/no-conditional-expect */
},
} as Room["currentState"],
} as unknown as Room;
@@ -785,6 +795,7 @@ describe("MatrixClient", function () {
getMyMembership: () => "join",
currentState: {
getStateEvents: (eventType, stateKey) => {
/* eslint-disable jest/no-conditional-expect */
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
@@ -803,6 +814,7 @@ describe("MatrixClient", function () {
} else {
throw new Error("Unexpected event type or state key");
}
/* eslint-enable jest/no-conditional-expect */
},
} as Room["currentState"],
} as unknown as Room;
@@ -820,6 +832,7 @@ describe("MatrixClient", function () {
getMyMembership: () => "join",
currentState: {
getStateEvents: (eventType, stateKey) => {
/* eslint-disable jest/no-conditional-expect */
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
@@ -837,6 +850,7 @@ describe("MatrixClient", function () {
} else {
throw new Error("Unexpected event type or state key");
}
/* eslint-enable jest/no-conditional-expect */
},
} as Room["currentState"],
} as unknown as Room;
@@ -858,6 +872,7 @@ describe("MatrixClient", function () {
const syncPromise = new Promise<void>((resolve, reject) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "SYNCING") {
// eslint-disable-next-line jest/no-conditional-expect
expect(httpLookups.length).toEqual(0);
client.removeListener(ClientEvent.Sync, syncListener);
resolve();
@@ -944,6 +959,7 @@ describe("MatrixClient", function () {
const wasPreparedPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
/* eslint-disable jest/no-conditional-expect */
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(2);
expect(client.retryImmediately()).toBe(true);
@@ -955,6 +971,7 @@ describe("MatrixClient", function () {
// unexpected state transition!
expect(state).toEqual(null);
}
/* eslint-enable jest/no-conditional-expect */
});
});
await client.startClient();
@@ -976,8 +993,10 @@ describe("MatrixClient", function () {
const isSyncingPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
if (state === "ERROR" && httpLookups.length > 0) {
/* eslint-disable jest/no-conditional-expect */
expect(httpLookups.length).toEqual(1);
expect(client.retryImmediately()).toBe(true);
/* eslint-enable jest/no-conditional-expect */
jest.advanceTimersByTime(1);
} else if (state === "RECONNECTING" && httpLookups.length > 0) {
jest.advanceTimersByTime(10000);
@@ -1004,6 +1023,7 @@ describe("MatrixClient", function () {
const wasPreparedPromise = new Promise((resolve) => {
client.on(ClientEvent.Sync, function syncListener(state) {
/* eslint-disable jest/no-conditional-expect */
if (state === "ERROR" && httpLookups.length > 0) {
expect(httpLookups.length).toEqual(3);
expect(client.retryImmediately()).toBe(true);
@@ -1015,6 +1035,7 @@ describe("MatrixClient", function () {
// unexpected state transition!
expect(state).toEqual(null);
}
/* eslint-enable jest/no-conditional-expect */
});
});
await client.startClient();
@@ -2691,4 +2712,295 @@ describe("MatrixClient", function () {
});
});
});
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
describe("SecretStorage wrappers", () => {
let mockSecretStorage: Mocked<ServerSideSecretStorageImpl>;
beforeEach(() => {
mockSecretStorage = {
getDefaultKeyId: jest.fn(),
hasKey: jest.fn(),
isStored: jest.fn(),
} as unknown as Mocked<ServerSideSecretStorageImpl>;
client["_secretStorage"] = mockSecretStorage;
});
it("hasSecretStorageKey", async () => {
mockSecretStorage.hasKey.mockResolvedValue(false);
expect(await client.hasSecretStorageKey("mykey")).toBe(false);
expect(mockSecretStorage.hasKey).toHaveBeenCalledWith("mykey");
});
it("isSecretStored", async () => {
const mockResult = { key: {} as SecretStorageKeyDescriptionAesV1 };
mockSecretStorage.isStored.mockResolvedValue(mockResult);
expect(await client.isSecretStored("mysecret")).toBe(mockResult);
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("mysecret");
});
it("getDefaultSecretStorageKeyId", async () => {
mockSecretStorage.getDefaultKeyId.mockResolvedValue("bzz");
expect(await client.getDefaultSecretStorageKeyId()).toEqual("bzz");
});
it("isKeyBackupKeyStored", async () => {
mockSecretStorage.isStored.mockResolvedValue(null);
expect(await client.isKeyBackupKeyStored()).toBe(null);
expect(mockSecretStorage.isStored).toHaveBeenCalledWith("m.megolm_backup.v1");
});
});
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
describe("Crypto wrappers", () => {
describe("exception if no crypto", () => {
it("isCrossSigningReady", () => {
expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled");
});
it("bootstrapCrossSigning", () => {
expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled");
});
it("isSecretStorageReady", () => {
expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled");
});
});
describe("defer to crypto backend", () => {
let mockCryptoBackend: Mocked<CryptoBackend>;
beforeEach(() => {
mockCryptoBackend = {
isCrossSigningReady: jest.fn(),
bootstrapCrossSigning: jest.fn(),
isSecretStorageReady: jest.fn(),
stop: jest.fn().mockResolvedValue(undefined),
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockCryptoBackend;
});
it("isCrossSigningReady", async () => {
const testResult = "test";
mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isCrossSigningReady()).toBe(testResult);
expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1);
});
it("bootstrapCrossSigning", async () => {
const testOpts = {};
mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined);
await client.bootstrapCrossSigning(testOpts);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts);
});
it("isSecretStorageReady", async () => {
client["cryptoBackend"] = mockCryptoBackend;
const testResult = "test";
mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isSecretStorageReady()).toBe(testResult);
expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1);
});
});
});
describe("paginateEventTimeline()", () => {
describe("notifications timeline", () => {
const unsafeNotification = {
actions: ["notify"],
room_id: "__proto__",
event: testUtils.mkMessage({
user: "@villain:server.org",
room: "!roomId:server.org",
msg: "I am nefarious",
}),
profile_tag: null,
read: true,
ts: 12345,
};
const goodNotification = {
actions: ["notify"],
room_id: "!favouriteRoom:server.org",
event: new MatrixEvent({
sender: "@bob:server.org",
room_id: "!roomId:server.org",
type: "m.call.invite",
content: {},
}),
profile_tag: null,
read: true,
ts: 12345,
};
const highlightNotification = {
actions: ["notify", { set_tweak: "highlight", value: true }],
room_id: "!roomId:server.org",
event: testUtils.mkMessage({
user: "@bob:server.org",
room: "!roomId:server.org",
msg: "I am highlighted banana",
}),
profile_tag: null,
read: true,
ts: 12345,
};
const setNotifsResponse = (notifications: any[] = []): void => {
const response: HttpLookup = {
method: "GET",
path: "/notifications",
data: { notifications: JSON.parse(JSON.stringify(notifications)) },
};
httpLookups = [response];
};
const callRule: IPushRule = {
actions: [PushRuleActionName.Notify],
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "m.call.invite",
},
],
default: true,
enabled: true,
rule_id: ".m.rule.call",
};
const masterRule: IPushRule = {
actions: [PushRuleActionName.DontNotify],
conditions: [],
default: true,
enabled: false,
rule_id: RuleId.Master,
};
const bananaRule = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
pattern: "banana",
rule_id: "banana",
default: false,
enabled: true,
} as IPushRule;
const pushRules = {
global: {
underride: [callRule],
override: [masterRule],
content: [bananaRule],
},
};
beforeEach(() => {
makeClient();
// this is how notif timeline is set up in react-sdk
const notifTimelineSet = new EventTimelineSet(undefined, {
timelineSupport: true,
pendingEvents: false,
});
notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
client.setNotifTimelineSet(notifTimelineSet);
setNotifsResponse();
client.setPushRules(pushRules);
});
it("should throw when trying to paginate forwards", async () => {
const timeline = client.getNotifTimelineSet()!.getLiveTimeline();
await expect(
async () => await client.paginateEventTimeline(timeline, { backwards: false }),
).rejects.toThrow("paginateNotifTimeline can only paginate backwards");
});
it("defaults limit to 30 events", async () => {
jest.spyOn(client.http, "authedRequest");
const timeline = client.getNotifTimelineSet()!.getLiveTimeline();
await client.paginateEventTimeline(timeline, { backwards: true });
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Get, "/notifications", {
limit: "30",
only: "highlight",
});
});
it("filters out unsafe notifications", async () => {
setNotifsResponse([unsafeNotification, goodNotification, highlightNotification]);
const timelineSet = client.getNotifTimelineSet()!;
const timeline = timelineSet.getLiveTimeline();
await client.paginateEventTimeline(timeline, { backwards: true });
// badNotification not added to timeline
const timelineEvents = timeline.getEvents();
expect(timelineEvents.length).toEqual(2);
});
it("sets push details on events and add to timeline", async () => {
setNotifsResponse([goodNotification, highlightNotification]);
const timelineSet = client.getNotifTimelineSet()!;
const timeline = timelineSet.getLiveTimeline();
await client.paginateEventTimeline(timeline, { backwards: true });
const [highlightEvent, goodEvent] = timeline.getEvents();
expect(highlightEvent.getPushActions()).toEqual({
notify: true,
tweaks: {
highlight: true,
},
});
expect(highlightEvent.getPushDetails().rule).toEqual({
...bananaRule,
kind: "content",
});
expect(goodEvent.getPushActions()).toEqual({
notify: true,
tweaks: {
highlight: false,
},
});
});
});
});
describe("pushers", () => {
const pusher = {
app_id: "test",
app_display_name: "Test App",
data: {},
device_display_name: "test device",
kind: "http",
lang: "en-NZ",
pushkey: "1234",
};
beforeEach(() => {
makeClient();
const response: HttpLookup = {
method: Method.Post,
path: "/pushers/set",
data: {},
};
httpLookups = [response];
jest.spyOn(client.http, "authedRequest").mockClear();
});
it("should make correct request to set pusher", async () => {
const result = await client.setPusher(pusher);
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, pusher);
expect(result).toEqual({});
});
it("should make correct request to remove pusher", async () => {
const result = await client.removePusher(pusher.pushkey, pusher.app_id);
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, {
pushkey: pusher.pushkey,
app_id: pusher.app_id,
kind: null,
});
expect(result).toEqual({});
});
});
});
+2
View File
@@ -265,11 +265,13 @@ describe("MSC3089Branch", () => {
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
expect(stateKey).toEqual(stateKeyOrder[stateFn.mock.calls.length - 1]);
if (stateKey === fileEventId) {
// eslint-disable-next-line jest/no-conditional-expect
expect(content).toMatchObject({
retained: true, // canary for copying state
active: false,
});
} else if (stateKey === fileEventId2) {
// eslint-disable-next-line jest/no-conditional-expect
expect(content).toMatchObject({
active: true,
version: 2,
+12 -20
View File
@@ -130,14 +130,8 @@ describe("MSC3089TreeSpace", () => {
return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" }));
});
client.invite = fn;
try {
await tree.invite(target, false, false);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<MatrixError>e).errcode).toEqual("M_FORBIDDEN");
}
await expect(tree.invite(target, false, false)).rejects.toThrow("MatrixError: Sample Failure");
expect(fn).toHaveBeenCalledTimes(1);
});
@@ -357,13 +351,18 @@ describe("MSC3089TreeSpace", () => {
.fn()
.mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
expect([tree.roomId, subspaceId]).toContain(roomId);
let expectedType: string;
let expectedStateKey: string;
if (roomId === subspaceId) {
expect(eventType).toEqual(EventType.SpaceParent);
expect(stateKey).toEqual(tree.roomId);
expectedType = EventType.SpaceParent;
expectedStateKey = tree.roomId;
} else {
expect(eventType).toEqual(EventType.SpaceChild);
expect(stateKey).toEqual(subspaceId);
expectedType = EventType.SpaceChild;
expectedStateKey = subspaceId;
}
expect(eventType).toEqual(expectedType);
expect(stateKey).toEqual(expectedStateKey);
expect(content).toMatchObject({ via: [domain] });
// return value not used
@@ -629,15 +628,8 @@ describe("MSC3089TreeSpace", () => {
});
it("should throw when setting an order at the top level space", async () => {
try {
// The tree is what we've defined as top level, so it should work
await tree.setOrder(2);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect((<Error>e).message).toEqual("Cannot set order of top level spaces currently");
}
// The tree is what we've defined as top level, so it should work
await expect(tree.setOrder(2)).rejects.toThrow("Cannot set order of top level spaces currently");
});
it("should return a stable order for unordered children", () => {
+92
View File
@@ -17,6 +17,7 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import { Crypto, IEventDecryptionResult } from "../../../src/crypto";
import { IAnnotatedPushRule, PushRuleActionName, TweakName } from "../../../src";
describe("MatrixEvent", () => {
it("should create copies of itself", () => {
@@ -216,4 +217,95 @@ describe("MatrixEvent", () => {
expect(encryptedEvent.replyEventId).toBeUndefined();
});
});
describe("push details", () => {
const pushRule = {
actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: true }],
pattern: "banana",
rule_id: "banana",
kind: "override",
default: false,
enabled: true,
} as IAnnotatedPushRule;
describe("setPushActions()", () => {
it("sets actions on event", () => {
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushActions(actions);
expect(event.getPushActions()).toBe(actions);
});
it("sets actions to undefined", () => {
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushActions(null);
// undefined is set on state
expect(event.getPushDetails().actions).toBe(undefined);
// but pushActions getter returns null when falsy
expect(event.getPushActions()).toBe(null);
});
it("clears existing push rule", () => {
const prevActions = { notify: true, tweaks: { highlight: true } };
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushDetails(prevActions, pushRule);
event.setPushActions(actions);
// rule is not in event push cache
expect(event.getPushDetails()).toEqual({ actions });
});
});
describe("setPushDetails()", () => {
it("sets actions and rule on event", () => {
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushDetails(actions, pushRule);
expect(event.getPushDetails()).toEqual({
actions,
rule: pushRule,
});
});
it("clears existing push rule", () => {
const prevActions = { notify: true, tweaks: { highlight: true } };
const actions = { notify: false, tweaks: {} };
const event = new MatrixEvent({
type: "com.example.test",
content: {
isTest: true,
},
});
event.setPushDetails(prevActions, pushRule);
event.setPushActions(actions);
// rule is not in event push cache
expect(event.getPushDetails()).toEqual({ actions });
});
});
});
});
+32 -2
View File
@@ -14,13 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
import { M_POLL_START } from "matrix-events-sdk";
import { EventType, IEvent, MatrixEvent, PollEvent, Room } from "../../../src";
import { REFERENCE_RELATION } from "../../../src/@types/extensible_events";
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls";
import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent";
import { Poll } from "../../../src/models/poll";
import { isPollEvent, Poll } from "../../../src/models/poll";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { flushPromises } from "../../test-utils/flushPromises";
import { mkEvent } from "../../test-utils/test-utils";
jest.useFakeTimers();
@@ -453,4 +456,31 @@ describe("Poll", () => {
expect(responses.getRelations()).toEqual([responseEvent]);
});
});
describe("isPollEvent", () => {
it("should return »false« for a non-poll event", () => {
const messageEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
content: {},
user: mockClient.getSafeUserId(),
room: room.roomId,
});
expect(isPollEvent(messageEvent)).toBe(false);
});
it.each([[M_POLL_START.name], [M_POLL_RESPONSE.name], [M_POLL_END.name]])(
"should return »true« for a »%s« event",
(type: string) => {
const pollEvent = mkEvent({
event: true,
type,
content: {},
user: mockClient.getSafeUserId(),
room: room.roomId,
});
expect(isPollEvent(pollEvent)).toBe(true);
},
);
});
});
+305 -5
View File
@@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
import { Room } from "../../../src/models/room";
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
import { mkThread } from "../../test-utils/thread";
import { Room, RoomEvent } from "../../../src/models/room";
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, FeatureSupport } from "../../../src/models/thread";
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, MatrixEvent } from "../../../src";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
import { Feature, ServerSupport } from "../../../src/feature";
import { eventMapperFor } from "../../../src/event-mapper";
describe("Thread", () => {
describe("constructor", () => {
@@ -424,4 +427,301 @@ describe("Thread", () => {
expect(mock).toHaveBeenCalledWith("b1", "f1");
});
});
describe("insertEventIntoTimeline", () => {
it("Inserts a reaction in timestamp order", () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
const client = createClientWithEventMapper();
const userId = "user1";
const room = new Room("room1", client, userId);
// Given a thread with a root plus 5 messages
const { thread, events } = mkThread({
room,
client,
authorId: userId,
participantUserIds: ["@bob:hs", "@chia:hs", "@dv:hs"],
length: 6,
ts: 100, // Events will be at ts 100, 101, 102, 103, 104 and 105
});
// When we insert a reaction to the second thread message
const replyEvent = mkReaction(events[2], client, userId, room.roomId, 104);
thread.insertEventIntoTimeline(replyEvent);
// Then the reaction is inserted based on its timestamp
expect(thread.events.map((ev) => ev.getId())).toEqual([
events[0].getId(),
events[1].getId(),
events[2].getId(),
events[3].getId(),
events[4].getId(),
replyEvent.getId(),
events[5].getId(),
]);
});
describe("Without relations recursion support", () => {
it("Creates a local echo receipt for new events", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client without relations recursion support
const client = createClientWithEventMapper();
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(message.getId());
expect(receipt?.data.ts).toEqual(100);
expect(receipt?.data.thread_id).toEqual(thread.id);
// (And the receipt was synthetic)
expect(thread.getReadReceiptForUserId(userId, true)).toBeNull();
});
it("Doesn't create a local echo receipt for events before an existing receipt", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client without relations recursion support
const client = createClientWithEventMapper();
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
// Then no receipt was added to the thread (the receipt is still
// for the thread root). This happens because since we have no
// recursive relations support, we know that sometimes events
// appear out of order, so we have to check their timestamps as
// a guess of the correct order.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
});
});
describe("With relations recursion support", () => {
it("Creates a local echo receipt for new events", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client WITH relations recursion support
const client = createClientWithEventMapper(
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
);
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId());
});
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client WITH relations recursion support
const client = createClientWithEventMapper(
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
);
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
// Then a receipt was added to the thread, because relations
// recursion is available, so we trust the server to have
// provided us with events in the right order.
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId());
});
});
async function createThreadAndEvent(
client: MatrixClient,
rootTs: number,
eventTs: number,
userId: string,
): Promise<{ thread: Thread; message: MatrixEvent }> {
const room = new Room("room1", client, userId);
// Given a thread
const { thread } = mkThread({
room,
client,
authorId: userId,
participantUserIds: [],
ts: rootTs,
});
// Sanity: the current receipt is for the thread root
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
// When we add a message that is before the latest receipt
const message = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: eventTs,
});
await thread.addEvent(message, false, true);
await awaitTimelineEvent;
return { thread, message };
}
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
const client = mock(MatrixClient, "MatrixClient");
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = canSupport;
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
mocked(client.supportsThreads).mockReturnValue(true);
return client;
}
});
describe("Editing events", () => {
describe("Given server support for threads", () => {
let previousThreadHasServerSideSupport: FeatureSupport;
beforeAll(() => {
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
Thread.hasServerSideSupport = FeatureSupport.Stable;
});
afterAll(() => {
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
});
it("Adds edits from sync to the thread timeline and applies them", async () => {
// Given a thread
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
// When a message and an edit are added to the thread
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
await thread.addEvent(messageToEdit, false);
await thread.addEvent(editEvent, false);
// Then both events end up in the timeline
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(editEvent);
expect(secondLastEvent).toBe(messageToEdit);
// And the first message has been edited
expect(secondLastEvent.getContent().body).toEqual("edit");
});
it("Adds edits fetched on demand to the thread timeline and applies them", async () => {
// Given we don't support recursive relations
const client = createClient(new Map([[Feature.RelationsRecursion, ServerSupport.Unsupported]]));
// And we have a thread
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
// When a message is added to the thread, and an edit to it is provided on demand
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
// (fetchEditsWhereNeeded only applies to encrypted messages for some reason)
messageToEdit.event.type = EventType.RoomMessageEncrypted;
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
mocked(client.relations).mockImplementation(async (_roomId, eventId) => {
if (eventId === messageToEdit.getId()) {
return { events: [editEvent] };
} else {
return { events: [] };
}
});
await thread.addEvent(messageToEdit, false);
// Then both events end up in the timeline
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(editEvent);
expect(secondLastEvent).toBe(messageToEdit);
// And the first message has been edited
expect(secondLastEvent.getContent().body).toEqual("edit");
});
});
});
});
/**
* Create a message event that lives in a thread
*/
function createThreadMessage(threadId: string, user: string, room: string, msg: string): MatrixEvent {
return makeThreadEvent({
event: true,
user,
room,
msg,
rootEventId: threadId,
replyToEventId: threadId,
});
}
/**
* Create a thread and wait for it to be properly initialised (so you can safely
* add events to it and expect them to appear in the timeline.
*/
async function createThread(client: MatrixClient, user: string, roomId: string): Promise<Thread> {
const root = mkMessage({ event: true, user, room: roomId, msg: "Thread root" });
const room = new Room(roomId, client, "@roomcreator:x");
// Ensure the root is in the room timeline
root.setThreadId(root.getId());
await room.addLiveEvents([root]);
// Create the thread and wait for it to be initialised
const thread = room.createThread(root.getId()!, root, [], false);
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
return thread;
}
/**
* Create a MatrixClient that supports threads and has all the methods used when
* creating a thread that call out to HTTP endpoints mocked out.
*/
function createClient(canSupport = new Map()): MatrixClient {
const client = mock(MatrixClient, "MatrixClient");
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = canSupport;
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
// Mock methods that call out to HTTP endpoints
jest.spyOn(client, "paginateEventTimeline").mockResolvedValue(true);
jest.spyOn(client, "relations").mockResolvedValue({ events: [] });
jest.spyOn(client, "fetchRoomEvent").mockResolvedValue({});
return client;
}
+105 -87
View File
@@ -1,6 +1,7 @@
import * as utils from "../test-utils/test-utils";
import { IActionsObject, PushProcessor } from "../../src/pushprocessor";
import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName } from "../../src";
import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName, RuleId } from "../../src";
import { mockClientMethodsUser } from "../test-utils/client";
describe("NotificationService", function () {
const testUserId = "@ali:matrix.org";
@@ -45,9 +46,8 @@ describe("NotificationService", function () {
},
};
},
credentials: {
userId: testUserId,
},
...mockClientMethodsUser(testUserId),
supportsIntentionalMentions: () => true,
pushRules: {
device: {},
global: {
@@ -97,51 +97,6 @@ describe("NotificationService", function () {
pattern: "foo*bar",
rule_id: "foobar",
},
{
actions: [
"notify",
{
set_tweak: "sound",
value: "default",
},
{
set_tweak: "highlight",
},
],
enabled: true,
pattern: "p[io]ng",
rule_id: "pingpong",
},
{
actions: [
"notify",
{
set_tweak: "sound",
value: "default",
},
{
set_tweak: "highlight",
},
],
enabled: true,
pattern: "I ate [0-9] pies",
rule_id: "pies",
},
{
actions: [
"notify",
{
set_tweak: "sound",
value: "default",
},
{
set_tweak: "highlight",
},
],
enabled: true,
pattern: "b[!ai]ke",
rule_id: "bakebike",
},
],
override: [
{
@@ -289,39 +244,6 @@ describe("NotificationService", function () {
expect(actions.tweaks.highlight).toEqual(true);
});
// TODO: This is not spec compliant behaviour.
//
// See https://spec.matrix.org/v1.5/client-server-api/#conditions-1 which
// describes pattern should glob:
//
// 1. * matches 0 or more characters;
// 2. ? matches exactly one character
it("should bing on character group ([abc]) bing words.", function () {
testEvent.event.content!.body = "Ping!";
let actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
testEvent.event.content!.body = "Pong!";
actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
});
// TODO: This is not spec compliant behaviour. (See above.)
it("should bing on character range ([a-z]) bing words.", function () {
testEvent.event.content!.body = "I ate 6 pies";
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
});
// TODO: This is not spec compliant behaviour. (See above.)
it("should bing on character negation ([!a]) bing words.", function () {
testEvent.event.content!.body = "boke";
let actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
testEvent.event.content!.body = "bake";
actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(false);
});
it("should not bing on room server ACL changes", function () {
testEvent = utils.mkEvent({
type: EventType.RoomServerAcl,
@@ -572,11 +494,76 @@ describe("NotificationService", function () {
});
const actions = pushProcessor.actionsForEvent(testEvent);
if (expected) {
expect(actions?.notify).toBeTruthy();
} else {
expect(actions?.notify).toBeFalsy();
}
expect(!!actions?.notify).toBe(expected);
});
});
describe("Test event property contains", () => {
it.each([
// Simple string matching.
{ value: "bar", eventValue: ["bar"], expected: true },
// Matches are case-sensitive.
{ value: "bar", eventValue: ["BAR"], expected: false },
// Values should not be type-coerced.
{ value: "bar", eventValue: [true], expected: false },
{ value: "bar", eventValue: [1], expected: false },
{ value: "bar", eventValue: [false], expected: false },
// Boolean matching.
{ value: true, eventValue: [true], expected: true },
{ value: false, eventValue: [false], expected: true },
// Types should not be coerced.
{ value: true, eventValue: ["true"], expected: false },
{ value: true, eventValue: [1], expected: false },
{ value: false, eventValue: [null], expected: false },
// Null matching.
{ value: null, eventValue: [null], expected: true },
// Types should not be coerced
{ value: null, eventValue: [false], expected: false },
{ value: null, eventValue: [0], expected: false },
{ value: null, eventValue: [""], expected: false },
{ value: null, eventValue: [undefined], expected: false },
// Non-array or empty values should never be matched.
{ value: "bar", eventValue: "bar", expected: false },
{ value: "bar", eventValue: { bar: true }, expected: false },
{ value: true, eventValue: { true: true }, expected: false },
{ value: true, eventValue: true, expected: false },
{ value: null, eventValue: [], expected: false },
{ value: null, eventValue: {}, expected: false },
{ value: null, eventValue: null, expected: false },
{ value: null, eventValue: undefined, expected: false },
])("test $value against $eventValue", ({ value, eventValue, expected }) => {
matrixClient.pushRules! = {
global: {
override: [
{
actions: [PushRuleActionName.Notify],
conditions: [
{
kind: ConditionKind.EventPropertyContains,
key: "content.foo",
value: value,
},
],
default: true,
enabled: true,
rule_id: ".m.rule.test",
},
],
},
};
testEvent = utils.mkEvent({
type: "m.room.message",
room: testRoomId,
user: "@alfred:localhost",
event: true,
content: {
foo: eventValue,
},
});
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions?.notify).toBe(expected ? true : undefined);
});
});
@@ -647,6 +634,37 @@ describe("NotificationService", function () {
});
});
});
describe("test intentional mentions behaviour", () => {
it.each([RuleId.ContainsUserName, RuleId.ContainsDisplayName, RuleId.AtRoomNotification])(
"Rule %s matches unless intentional mentions are enabled",
(ruleId) => {
const rule = {
rule_id: ruleId,
actions: [],
conditions: [],
default: false,
enabled: true,
};
expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(true);
// Add the mentions property to the event and the rule is now disabled.
testEvent = utils.mkEvent({
type: "m.room.message",
room: testRoomId,
user: "@alfred:localhost",
event: true,
content: {
"body": "",
"msgtype": "m.text",
"org.matrix.msc3952.mentions": {},
},
});
expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(false);
},
);
});
});
describe("Test PushProcessor.partsForDottedKey", function () {
+215
View File
@@ -0,0 +1,215 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ReceiptType } from "../../src/@types/read_receipts";
import { ReceiptAccumulator } from "../../src/receipt-accumulator";
const roomId = "!foo:bar";
describe("ReceiptAccumulator", function () {
/*
* Note: at the time of writing, the ReceiptAccumulator uses the order of
* events from sync as the determinant of which one is most recent. This
* is correct, but inconsistent with other areas of the code, since we
* don't persist this order, so in other places we are forced to use the
* timestamp of events and hope that this matches up.
*
* The tests in this file provide ts values that are consistent with the
* sync order, so they should still pass if we change to using ts to
* determine order.
*
* Ideally, we would keep track of sync order so we can use it everywhere.
* This would mean we are consistent with how the homeserver sees receipts
* and notifications.
*/
it("Discards previous unthreaded receipts for the same user", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2));
});
it("Discards previous threaded receipts for the same user in the same thread", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1"));
});
it("Collects multiple receipts for the same user if they are in different threads", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
["$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
]),
);
});
it("Collects multiple receipts for different users", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
["$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
]),
);
});
it("Collects last receipt for various users and threads", () => {
const acc = new ReceiptAccumulator();
// Below, if ts=1, this receipt is going to be superceded by another
// with ts=2
const receipts = [
newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1),
newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 1, "thread1"),
newReceipt("$event3", ReceiptType.Read, "@alice:localhost", 1, "thread1"),
newReceipt("$event4", ReceiptType.Read, "@bobby:localhost", 1),
newReceipt("$event5", ReceiptType.Read, "@bobby:localhost", 2),
newReceipt("$event6", ReceiptType.Read, "@alice:localhost", 2),
newReceipt("$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"),
newReceipt("$event8", ReceiptType.Read, "@bobby:localhost", 1, "thread2"),
newReceipt("$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"),
newReceipt("$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"),
newReceipt("$eventB", ReceiptType.Read, "@alice:localhost", 1, "thread2"),
newReceipt("$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"),
];
acc.consumeEphemeralEvents(receipts);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// Only the ts=2 receipts make it through
expect(newEvent).toEqual(
newMultiReceipt([
["$event5", ReceiptType.Read, "@bobby:localhost", 2, undefined],
["$event6", ReceiptType.Read, "@alice:localhost", 2, undefined],
["$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
["$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"],
["$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"],
["$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
]),
);
});
it("Keeps main thread receipts even when an unthreaded receipt came later", () => {
const acc = new ReceiptAccumulator();
// Given receipts for the special thread "main" and also unthreaded
// receipts (which have no thread id).
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "main");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
// When we collect them
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// We preserve both: thread:main and unthreaded receipts are different
// things, with different meanings.
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "main"],
["$event2", ReceiptType.Read, "@alice:localhost", 2, undefined],
]),
);
});
it("Keeps unthreaded receipts even when a main thread receipt came later", () => {
const acc = new ReceiptAccumulator();
// Given receipts for the special thread "main" and also unthreaded
// receipts (which have no thread id).
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "main");
// When we collect them
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// We preserve both: thread:main and unthreaded receipts are different
// things, with different meanings.
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, undefined],
["$event2", ReceiptType.Read, "@alice:localhost", 2, "main"],
]),
);
});
});
const newReceipt = (
eventId: string,
receiptType: ReceiptType,
userId: string,
ts: number,
threadId: string | undefined = undefined,
) => {
return {
type: "m.receipt",
room_id: roomId,
content: {
[eventId]: {
[receiptType]: {
[userId]: { ts, thread_id: threadId },
},
},
},
};
};
const newMultiReceipt = (infoArray: Array<[string, ReceiptType, string, number, string | undefined]>) => {
return {
type: "m.receipt",
room_id: roomId,
content: Object.fromEntries(
infoArray.map(([eventId, receiptType, userId, ts, threadId]) => {
return [
eventId,
{
[receiptType]: {
[userId]: { ts, thread_id: threadId },
},
},
];
}),
),
};
};
+35 -2
View File
@@ -38,6 +38,7 @@ function makeMockClient(opts: {
deviceId: string;
deviceKey?: string;
msc3882Enabled: boolean;
msc3882r0Only: boolean;
msc3886Enabled: boolean;
devices?: Record<string, Partial<DeviceInfo>>;
verificationFunction?: (
@@ -58,6 +59,17 @@ function makeMockClient(opts: {
},
};
},
getCapabilities() {
return opts.msc3882r0Only
? {}
: {
capabilities: {
"org.matrix.msc3882.get_login_token": {
enabled: opts.msc3882Enabled,
},
},
};
},
getUserId() {
return opts.userId;
},
@@ -111,6 +123,7 @@ describe("Rendezvous", function () {
deviceId: "DEVICEID",
msc3886Enabled: false,
msc3882Enabled: true,
msc3882r0Only: true,
});
httpBackend.when("POST", "https://fallbackserver/rz").response = {
body: null,
@@ -166,7 +179,13 @@ describe("Rendezvous", function () {
await aliceRz.close();
});
it("no protocols", async function () {
async function testNoProtocols({
msc3882Enabled,
msc3882r0Only,
}: {
msc3882Enabled: boolean;
msc3882r0Only: boolean;
}) {
const aliceTransport = makeTransport("Alice");
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
transports.push(aliceTransport, bobTransport);
@@ -178,8 +197,9 @@ describe("Rendezvous", function () {
const alice = makeMockClient({
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: false,
msc3886Enabled: false,
msc3882Enabled,
msc3882r0Only,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
const aliceRz = new MSC3906Rendezvous(aliceEcdh, alice);
@@ -218,6 +238,14 @@ describe("Rendezvous", function () {
await aliceStartProm;
await bobStartPromise;
}
it("no protocols - r0", async function () {
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: true });
});
it("no protocols - r1", async function () {
await testNoProtocols({ msc3882Enabled: false, msc3882r0Only: false });
});
it("new device declines protocol with outcome unsupported", async function () {
@@ -233,6 +261,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -291,6 +320,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -349,6 +379,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -409,6 +440,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
});
const aliceEcdh = new MSC3903ECDHRendezvousChannel(aliceTransport, undefined, aliceOnFailure);
@@ -477,6 +509,7 @@ describe("Rendezvous", function () {
userId: "alice",
deviceId: "ALICE",
msc3882Enabled: true,
msc3882r0Only: false,
msc3886Enabled: false,
devices,
deviceKey: "aaaa",
+23 -18
View File
@@ -25,6 +25,8 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
import { MatrixClient } from "../../src/client";
import { DecryptionError } from "../../src/crypto/algorithms";
import { defer } from "../../src/utils";
describe("RoomState", function () {
const roomId = "!foo:bar";
@@ -886,7 +888,7 @@ describe("RoomState", function () {
expect(emitSpy).not.toHaveBeenCalled();
});
it("adds locations to beacons", () => {
it("adds locations to beacons", async () => {
const location1 = makeBeaconEvent(userA, {
beaconInfoId: "$beacon1",
timestamp: Date.now() + 1,
@@ -906,7 +908,7 @@ describe("RoomState", function () {
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beaconInstance, "addLocations");
state.processBeaconEvents([location1, location2, location3], mockClient);
await state.processBeaconEvents([location1, location2, location3], mockClient);
expect(addLocationsSpy).toHaveBeenCalledTimes(2);
// only called with locations for beacon1
@@ -978,51 +980,50 @@ describe("RoomState", function () {
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it("decrypts related events if needed", () => {
it("decrypts related events if needed", async () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: beacon1.getId(),
});
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
await state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
});
it("listens for decryption on events that are being decrypted", () => {
it("awaits for decryption on events that are being decrypted", async () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once");
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
await state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalled();
});
it("listens for decryption on events that have decryption failure", () => {
it("listens for decryption on events that have decryption failure", async () => {
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true);
mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg"));
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once");
const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once");
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
await state.processBeaconEvents([failedDecryptionRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it("discard events that are not m.beacon type after decryption", () => {
it("discard events that are not m.beacon type after decryption", async () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
@@ -1032,7 +1033,7 @@ describe("RoomState", function () {
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
await state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// this event is a message after decryption
decryptingRelatedEvent.event.type = EventType.RoomMessage;
@@ -1041,7 +1042,7 @@ describe("RoomState", function () {
expect(addLocationsSpy).not.toHaveBeenCalled();
});
it("adds locations to beacons after decryption", () => {
it("adds locations to beacons after decryption", async () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
@@ -1051,16 +1052,20 @@ describe("RoomState", function () {
beaconInfoId: "$beacon1",
timestamp: Date.now() + 1,
});
jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true);
const deferred = defer<void>();
mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
const prom = state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.event.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
deferred.resolve();
await prom;
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
});
+422 -250
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
describe("CrossSigningIdentity", () => {
describe("bootstrapCrossSigning", () => {
/** the CrossSigningIdentity implementation under test */
let crossSigning: CrossSigningIdentity;
/** a mocked-up OlmMachine which crossSigning is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
/** A mock OutgoingRequestProcessor which crossSigning is connected to */
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
beforeEach(async () => {
await RustSdkCryptoJs.initAsync();
olmMachine = {
crossSigningStatus: jest.fn(),
bootstrapCrossSigning: jest.fn(),
close: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor);
});
it("should do nothing if keys are present on-device and in secret storage", async () => {
olmMachine.crossSigningStatus.mockResolvedValue({
hasMaster: true,
hasSelfSigning: true,
hasUserSigning: true,
});
// TODO: secret storage
await crossSigning.bootstrapCrossSigning({});
expect(olmMachine.bootstrapCrossSigning).not.toHaveBeenCalled();
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
});
it("should call bootstrapCrossSigning if a reset is forced", async () => {
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
await crossSigning.bootstrapCrossSigning({ setupNewCrossSigning: true });
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
});
it("should call bootstrapCrossSigning if we need new keys", async () => {
olmMachine.crossSigningStatus.mockResolvedValue({
hasMaster: false,
hasSelfSigning: false,
hasUserSigning: false,
});
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
await crossSigning.bootstrapCrossSigning({});
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
});
});
});
@@ -24,11 +24,12 @@ import {
KeysUploadRequest,
RoomMessageRequest,
SignatureUploadRequest,
SigningKeysUploadRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
describe("OutgoingRequestProcessor", () => {
@@ -80,6 +81,12 @@ describe("OutgoingRequestProcessor", () => {
"https://example.com/_matrix/client/v3/keys/signatures/upload",
],
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
[
"SigningKeysUploadRequest",
SigningKeysUploadRequest,
"POST",
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
],
];
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
@@ -171,6 +178,40 @@ describe("OutgoingRequestProcessor", () => {
httpBackend.verifyNoOutstandingRequests();
});
it("should handle SigningKeysUploadRequests with UIA", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
// also create a UIA callback
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
return await makeRequest({ type: "test" });
};
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
// Now: check that it makes a matching HTTP request ...
const testResponse = '{"result":1}';
httpBackend
.when("POST", "/_matrix")
.check((req) => {
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
expect(JSON.parse(req.rawData)).toEqual({ foo: "bar", auth: { type: "test" } });
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await Promise.all([reqProm, markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
const markSentCallPromise = awaitCallToMarkAsSent();
@@ -0,0 +1,68 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { DeviceKeys, DeviceVerification } from "../../../src";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for QueryDevice initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("downloadDeviceToJsDevice", () => {
it("should convert a QueryDevice to a Device", () => {
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
keys,
algorithms,
device_id: deviceId,
user_id: userId,
signatures,
unsigned,
};
const device = downloadDeviceToJsDevice(queryDevice);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(DeviceVerification.Unverified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
keys,
algorithms,
device_id: deviceId,
user_id: userId,
};
const device = downloadDeviceToJsDevice(queryDevice);
expect(device.signatures.size).toBe(0);
});
});
});
+113 -13
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,11 +22,12 @@ import { Mocked } from "jest-mock";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -35,16 +36,15 @@ afterEach(() => {
indexedDB = new IDBFactory();
});
describe("RustCrypto", () => {
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe("RustCrypto", () => {
describe(".exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should return a list", async () => {
@@ -53,12 +53,11 @@ describe("RustCrypto", () => {
});
});
describe("to-device messages", () => {
describe("call preprocess methods", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should pass through unencrypted to-device messages", async () => {
@@ -94,6 +93,32 @@ describe("RustCrypto", () => {
});
});
it("isCrossSigningReady", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false);
});
it("getCrossSigningKeyId", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
});
it("bootstrapCrossSigning delegates to CrossSigningIdentity", async () => {
const rustCrypto = await makeTestRustCrypto();
const mockCrossSigningIdentity = {
bootstrapCrossSigning: jest.fn().mockResolvedValue(undefined),
};
// @ts-ignore private property
rustCrypto.crossSigningIdentity = mockCrossSigningIdentity;
await rustCrypto.bootstrapCrossSigning({});
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
});
it("isSecretStorageReady", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
});
describe("outgoing requests", () => {
/** the RustCrypto implementation under test */
let rustCrypto: RustCrypto;
@@ -141,7 +166,13 @@ describe("RustCrypto", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
rustCrypto = new RustCrypto(
olmMachine,
{} as MatrixHttpApi<any>,
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
);
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
});
@@ -206,8 +237,7 @@ describe("RustCrypto", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should handle unencrypted events", () => {
@@ -230,4 +260,74 @@ describe("RustCrypto", () => {
expect(res.encrypted).toBeTruthy();
});
});
describe("get|setTrustCrossSignedDevices", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto();
});
it("should be true by default", () => {
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
});
it("should be easily turn-off-and-on-able", () => {
rustCrypto.setTrustCrossSignedDevices(false);
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(false);
rustCrypto.setTrustCrossSignedDevices(true);
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
});
});
describe("getDeviceVerificationStatus", () => {
let rustCrypto: RustCrypto;
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
beforeEach(() => {
olmMachine = {
getDevice: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
);
});
it("should call getDevice", async () => {
olmMachine.getDevice.mockResolvedValue({
isCrossSigningTrusted: jest.fn().mockReturnValue(false),
isLocallyTrusted: jest.fn().mockReturnValue(false),
isCrossSignedByOwner: jest.fn().mockReturnValue(false),
} as unknown as RustSdkCryptoJs.Device);
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
expect(olmMachine.getDevice.mock.calls[0][0].toString()).toEqual("@user:domain");
expect(olmMachine.getDevice.mock.calls[0][1].toString()).toEqual("device");
expect(res?.crossSigningVerified).toBe(false);
expect(res?.localVerified).toBe(false);
expect(res?.signedByOwner).toBe(false);
});
it("should return null for unknown device", async () => {
olmMachine.getDevice.mockResolvedValue(undefined);
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
expect(res).toBe(null);
});
});
});
/** build a basic RustCrypto instance for testing
*
* just provides default arguments for initRustCrypto()
*/
async function makeTestRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }> = {} as MatrixClient["http"],
userId: string = TEST_USER,
deviceId: string = TEST_DEVICE_ID,
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
): Promise<RustCrypto> {
return await initRustCrypto(http, userId, deviceId, secretStorage);
}
+5
View File
@@ -58,10 +58,12 @@ describe("MatrixScheduler", function () {
let yieldedA = false;
scheduler.setProcessFunction(function (event) {
if (yieldedA) {
// eslint-disable-next-line jest/no-conditional-expect
expect(event).toEqual(eventB);
return deferB.promise;
} else {
yieldedA = true;
// eslint-disable-next-line jest/no-conditional-expect
expect(event).toEqual(eventA);
return deferA.promise;
}
@@ -89,6 +91,7 @@ describe("MatrixScheduler", function () {
scheduler.setProcessFunction(function (ev) {
procCount += 1;
if (procCount === 1) {
// eslint-disable-next-line jest/no-conditional-expect
expect(ev).toEqual(eventA);
return deferred.promise;
} else if (procCount === 2) {
@@ -129,9 +132,11 @@ describe("MatrixScheduler", function () {
scheduler.setProcessFunction(function (ev) {
procCount += 1;
if (procCount === 1) {
// eslint-disable-next-line jest/no-conditional-expect
expect(ev).toEqual(eventA);
return deferA.promise;
} else if (procCount === 2) {
// eslint-disable-next-line jest/no-conditional-expect
expect(ev).toEqual(eventB);
return deferB.promise;
}
+270
View File
@@ -0,0 +1,270 @@
/*
Copyright 2019, 2022-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mocked } from "jest-mock";
import {
AccountDataClient,
PassphraseInfo,
SecretStorageCallbacks,
SecretStorageKeyDescriptionAesV1,
SecretStorageKeyDescriptionCommon,
ServerSideSecretStorageImpl,
trimTrailingEquals,
} from "../../src/secret-storage";
import { calculateKeyCheck } from "../../src/crypto/aes";
import { randomString } from "../../src/randomstring";
describe("ServerSideSecretStorageImpl", function () {
describe(".addKey", function () {
it("should allow storing a default key", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
// it should have made up a 32-character key id
expect(result.keyId.length).toEqual(32);
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
`m.secret_storage.key.${result.keyId}`,
result.keyInfo,
);
});
it("should allow storing a key with an explicit id", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {}, "myKeyId");
// it should have made up a 32-character key id
expect(result.keyId).toEqual("myKeyId");
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
"m.secret_storage.key.myKeyId",
result.keyInfo,
);
});
it("should allow storing a key with a name", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", { name: "mykey" });
expect(result.keyInfo.name).toEqual("mykey");
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
`m.secret_storage.key.${result.keyId}`,
result.keyInfo,
);
});
it("should allow storing a key with a passphrase", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const passphrase: PassphraseInfo = {
algorithm: "m.pbkdf2",
iterations: 125,
salt: "saltygoodness",
bits: 256,
};
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
passphrase,
});
expect(result.keyInfo.passphrase).toEqual(passphrase);
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith(
`m.secret_storage.key.${result.keyId}`,
result.keyInfo,
);
});
it("should complain about invalid algorithm", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
await expect(() => secretStorage.addKey("bad_alg")).rejects.toThrow("Unknown key algorithm");
});
});
describe("getKey", function () {
it("should return the specified key", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
if (eventType === "m.secret_storage.key.my_key") {
return storedKey as unknown as T;
} else {
throw new Error(`unexpected eventType ${eventType}`);
}
}
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
const result = await secretStorage.getKey("my_key");
expect(result).toEqual(["my_key", storedKey]);
});
it("should return the default key if none is specified", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const storedKey = { iv: "iv", mac: "mac" } as SecretStorageKeyDescriptionAesV1;
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
if (eventType === "m.secret_storage.default_key") {
return { key: "default_key_id" } as unknown as T;
} else if (eventType === "m.secret_storage.key.default_key_id") {
return storedKey as unknown as T;
} else {
throw new Error(`unexpected eventType ${eventType}`);
}
}
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
const result = await secretStorage.getKey();
expect(result).toEqual(["default_key_id", storedKey]);
});
it("should return null if the key is not found", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
// @ts-ignore
accountDataAdapter.getAccountDataFromServer.mockResolvedValue(null);
const result = await secretStorage.getKey("my_key");
expect(result).toEqual(null);
});
});
describe("checkKey", function () {
it("should return true for a correct key check", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const myKey = new TextEncoder().encode(randomString(32));
const { iv, mac } = await calculateKeyCheck(myKey);
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv,
mac,
};
const result = await secretStorage.checkKey(myKey, keyInfo);
expect(result).toBe(true);
});
it("should return false for an incorrect key check", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const { iv, mac } = await calculateKeyCheck(new TextEncoder().encode("badkey"));
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv,
mac,
};
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
expect(result).toBe(false);
});
it("should raise for an unknown algorithm", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "bad_alg",
iv: "iv",
mac: "mac",
};
await expect(() => secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo)).rejects.toThrow(
"Unknown algorithm",
);
});
// XXX: really???
it("should return true for an absent mac", async function () {
const secretStorage = new ServerSideSecretStorageImpl({} as AccountDataClient, {});
const keyInfo: SecretStorageKeyDescriptionAesV1 = {
name: "my key",
passphrase: {} as PassphraseInfo,
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv: "iv",
mac: "",
};
const result = await secretStorage.checkKey(new TextEncoder().encode("goodkey"), keyInfo);
expect(result).toBe(true);
});
});
describe("store", () => {
it("should ignore keys with unknown algorithm", async function () {
const accountDataAdapter = mockAccountDataClient();
const mockCallbacks = { getSecretStorageKey: jest.fn() } as Mocked<SecretStorageCallbacks>;
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, mockCallbacks);
// stub out getAccountData to return a key with an unknown algorithm
const storedKey = { algorithm: "badalg" } as SecretStorageKeyDescriptionCommon;
async function mockGetAccountData<T extends Record<string, any>>(eventType: string): Promise<T | null> {
if (eventType === "m.secret_storage.key.keyid") {
return storedKey as unknown as T;
} else {
throw new Error(`unexpected eventType ${eventType}`);
}
}
accountDataAdapter.getAccountDataFromServer.mockImplementation(mockGetAccountData);
// suppress the expected warning on the console
jest.spyOn(console, "warn").mockImplementation();
// now attempt the store
await secretStorage.store("mysecret", "supersecret", ["keyid"]);
// we should have stored... nothing
expect(accountDataAdapter.setAccountData).toHaveBeenCalledWith("mysecret", { encrypted: {} });
// ... and emitted a warning.
// eslint-disable-next-line no-console
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("unknown algorithm"));
});
});
});
describe("trimTrailingEquals", () => {
it("should strip trailing =", () => {
expect(trimTrailingEquals("ab=c===")).toEqual("ab=c");
});
it("should leave strings without trailing = alone", () => {
expect(trimTrailingEquals("ab=c")).toEqual("ab=c");
});
it("should leave the empty string alone", () => {
const result = trimTrailingEquals("");
expect(result).toEqual("");
});
});
function mockAccountDataClient(): Mocked<AccountDataClient> {
return {
getAccountDataFromServer: jest.fn().mockResolvedValue(null),
setAccountData: jest.fn().mockResolvedValue({}),
} as unknown as Mocked<AccountDataClient>;
}
@@ -0,0 +1,47 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker";
import { defer } from "../../../src/utils";
function setupWorker(worker: IndexedDBStoreWorker): void {
worker.onMessage({ data: { command: "setupWorker", args: [] } } as any);
worker.onMessage({ data: { command: "connect", seq: 1 } } as any);
}
describe("IndexedDBStore Worker", () => {
it("should pass 'closed' event via postMessage", async () => {
const deferred = defer<void>();
const postMessage = jest.fn().mockImplementation(({ seq, command }) => {
if (seq === 1 && command === "cmd_success") {
deferred.resolve();
}
});
const worker = new IndexedDBStoreWorker(postMessage);
setupWorker(worker);
await deferred.promise;
// @ts-ignore - private field access
(worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
expect(postMessage).toHaveBeenCalledWith({
command: "closed",
});
});
});
+115
View File
@@ -166,4 +166,119 @@ describe("IndexedDBStore", () => {
await expect(store.isNewlyCreated()).resolves.toBeFalsy();
});
it("should emit 'closed' if database is unexpectedly closed", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
const deferred = defer<void>();
store.on("closed", deferred.resolve);
// @ts-ignore - private field access
(store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event);
await deferred.promise;
});
it("should use remote backend if workerFactory passed", async () => {
const deferred = defer<void>();
class MockWorker {
postMessage(data: any) {
if (data.command === "setupWorker") {
deferred.resolve();
}
}
}
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => new MockWorker() as Worker,
});
store.startup();
await deferred.promise;
});
it("remote worker should pass closed event", async () => {
const worker = new (class MockWorker {
postMessage(data: any) {}
})() as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
store.startup();
const deferred = defer<void>();
store.on("closed", deferred.resolve);
(worker as any).onmessage({ data: { command: "closed" } });
await deferred.promise;
});
it("remote worker should pass command failures", async () => {
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
if (data.command === "setupWorker" || data.command === "connect") {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
},
});
return;
}
this.onmessage({
data: {
command: "cmd_fail",
seq: data.seq,
error: new Error("Test"),
},
});
}
})() as unknown as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await expect(store.startup()).rejects.toThrow("Test");
});
it("remote worker should terminate upon destroy call", async () => {
const terminate = jest.fn();
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
result: [],
},
});
}
public terminate = terminate;
})() as unknown as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await store.startup();
await expect(store.destroy()).resolves;
expect(terminate).toHaveBeenCalled();
});
});
+1 -1
View File
@@ -59,7 +59,7 @@ describe("MemoryStore", () => {
await store.deleteAllData();
// empty object
expect(store.accountData).toEqual({});
expect(store.accountData).toEqual(new Map());
});
});
});
+90
View File
@@ -356,6 +356,77 @@ describe("SyncAccumulator", function () {
});
});
it("can handle large numbers of identical receipts", () => {
const testSize = 1000; // Make this big to check performance (e.g. 10 million ~= 10s)
const newReceipt = (ts: number) => {
return {
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event1:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts },
},
},
},
};
};
const receipts = [];
for (let i = 0; i < testSize; i++) {
receipts.push(newReceipt(testSize - i));
}
sa.accumulate(
syncSkeleton({
ephemeral: {
events: receipts,
},
}),
);
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
expect(events.length).toEqual(1);
expect(events[0]).toEqual(newReceipt(1));
});
it("can handle large numbers of receipts for different users and events", () => {
const testSize = 100; // Make this big to check performance (e.g. 1 million ~= 10s)
const newReceipt = (ts: number) => {
return {
type: "m.receipt",
room_id: "!foo:bar",
content: {
[`$event${ts}:localhost`]: {
[ReceiptType.Read]: {
[`@alice${ts}:localhost`]: { ts },
},
},
},
};
};
const receipts = [];
for (let i = 0; i < testSize; i++) {
receipts.push(newReceipt(testSize - i));
}
sa.accumulate(
syncSkeleton({
ephemeral: {
events: receipts,
},
}),
);
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
expect(events.length).toEqual(1);
expect(events[0]["content"]["$event1:localhost"]).toEqual({ "m.read": { "@alice1:localhost": { ts: 1 } } });
expect(Object.keys(events[0]["content"]).length).toEqual(testSize);
});
it("should accumulate threaded read receipts", () => {
const receipt1 = {
type: "m.receipt",
@@ -474,6 +545,25 @@ describe("SyncAccumulator", function () {
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
});
it("should correctly update summary properties to zero", function () {
// When we receive updates of a summary property, the last of which is 0
sa.accumulate(
createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}),
);
sa.accumulate(
createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 0,
}),
);
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
// Then we give an answer of 0
expect(summary["m.invited_member_count"]).toEqual(0);
});
it("should return correctly adjusted age attributes", () => {
const delta = 1000;
const startingTs = 1000;
+119
View File
@@ -24,9 +24,14 @@ import {
lexicographicCompare,
nextString,
prevString,
recursiveMapToObject,
simpleRetryOperation,
stringToBase,
sortEventsByLatestContentTimestamp,
safeSet,
MapWithDefault,
globToRegexp,
escapeRegExp,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
@@ -606,6 +611,105 @@ describe("utils", function () {
});
});
describe("recursiveMapToObject", () => {
it.each([
// empty map
{
map: new Map(),
expected: {},
},
// one level map
{
map: new Map<any, any>([
["key1", "value 1"],
["key2", 23],
["key3", undefined],
["key4", null],
["key5", [1, 2, 3]],
]),
expected: { key1: "value 1", key2: 23, key3: undefined, key4: null, key5: [1, 2, 3] },
},
// two level map
{
map: new Map<any, any>([
[
"key1",
new Map<any, any>([
["key1_1", "value 1"],
["key1_2", "value 1.2"],
]),
],
["key2", "value 2"],
]),
expected: { key1: { key1_1: "value 1", key1_2: "value 1.2" }, key2: "value 2" },
},
// multi level map
{
map: new Map<any, any>([
["key1", new Map<any, any>([["key1_1", new Map<any, any>([["key1_1_1", "value 1.1.1"]])]])],
]),
expected: { key1: { key1_1: { key1_1_1: "value 1.1.1" } } },
},
// list of maps
{
map: new Map<any, any>([
[
"key1",
[new Map<any, any>([["key1_1", "value 1.1"]]), new Map<any, any>([["key1_2", "value 1.2"]])],
],
]),
expected: { key1: [{ key1_1: "value 1.1" }, { key1_2: "value 1.2" }] },
},
// map → array → array → map
{
map: new Map<any, any>([["key1", [[new Map<any, any>([["key2", "value 2"]])]]]]),
expected: {
key1: [
[
{
key2: "value 2",
},
],
],
},
},
])("%# should convert the value", ({ map, expected }) => {
expect(recursiveMapToObject(map)).toStrictEqual(expected);
});
});
describe("safeSet", () => {
it("should set a value", () => {
const obj: Record<string, string> = {};
safeSet(obj, "testProp", "test value");
expect(obj).toEqual({ testProp: "test value" });
});
it.each(["__proto__", "prototype", "constructor"])("should raise an error when setting »%s«", (prop) => {
expect(() => {
safeSet(<Record<string, string>>{}, prop, "teset value");
}).toThrow("Trying to modify prototype or constructor");
});
});
describe("MapWithDefault", () => {
it("getOrCreate should create the value if it does not exist", () => {
const newValue = {};
const map = new MapWithDefault(() => newValue);
// undefined before getOrCreate
expect(map.get("test")).toBeUndefined();
expect(map.getOrCreate("test")).toBe(newValue);
// default value after getOrCreate
expect(map.get("test")).toBe(newValue);
// test that it always returns the same value
expect(map.getOrCreate("test")).toBe(newValue);
});
});
describe("sleep", () => {
it("resolves", async () => {
await utils.sleep(0);
@@ -623,4 +727,19 @@ describe("utils", function () {
await utils.immediate();
});
});
describe("escapeRegExp", () => {
it("should escape XYZ", () => {
expect(escapeRegExp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]")).toMatchInlineSnapshot(
`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`,
);
});
});
describe("globToRegexp", () => {
it("should not explode when given regexes as globs", () => {
const result = globToRegexp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]");
expect(result).toMatchInlineSnapshot(`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`);
});
});
});
+214 -11
View File
@@ -25,6 +25,7 @@ import {
CallType,
CallState,
CallParty,
CallDirection,
} from "../../../src/webrtc/call";
import {
MCallAnswer,
@@ -431,6 +432,58 @@ describe("Call", function () {
expect(transceivers.get("m.usermedia:video")!.sender.track!.id).toBe("usermedia_video_track");
});
it("should handle error on call upgrade", async () => {
const onError = jest.fn();
call.on(CallEvent.Error, onError);
await startVoiceCall(client, call);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
}),
);
const mockGetUserMediaStream = jest.fn().mockRejectedValue(new Error("Test error"));
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
expect(onError).toHaveBeenCalled();
});
it("should unmute video after upgrading to video call", async () => {
// Regression test for https://github.com/vector-im/element-call/issues/925
await startVoiceCall(client, call);
// start off with video muted
await call.setLocalVideoMuted(true);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
}),
);
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
// video should now be unmuted
expect(call.isLocalVideoMuted()).toBe(false);
});
it("should handle SDPStreamMetadata changes", async () => {
await startVoiceCall(client, call);
@@ -712,11 +765,22 @@ describe("Call", function () {
const dataChannel = call.createDataChannel("data_channel_label", { id: 123 });
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel);
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel, call);
expect(dataChannel.label).toBe("data_channel_label");
expect(dataChannel.id).toBe(123);
});
it("should emit a data channel event when the other side adds a data channel", async () => {
await startVoiceCall(client, call);
const dataChannelCallback = jest.fn();
call.on(CallEvent.DataChannel, dataChannelCallback);
(call.peerConn as unknown as MockRTCPeerConnection).triggerIncomingDataChannel();
expect(dataChannelCallback).toHaveBeenCalled();
});
describe("supportsMatrixCall", () => {
it("should return true when the environment is right", () => {
expect(supportsMatrixCall()).toBe(true);
@@ -990,14 +1054,7 @@ describe("Call", function () {
mockSendEvent.mockReset();
let caught = false;
try {
call.reject();
} catch (e) {
caught = true;
}
expect(caught).toEqual(true);
expect(() => call.reject()).toThrow();
expect(client.client.sendEvent).not.toHaveBeenCalled();
call.hangup(CallErrorCode.UserHangup, true);
@@ -1579,7 +1636,7 @@ describe("Call", function () {
hasAdvancedBy += advanceBy;
expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy, call);
}
});
@@ -1589,12 +1646,18 @@ describe("Call", function () {
beforeEach(async () => {
jest.useFakeTimers();
jest.spyOn(call, "hangup");
await fakeIncomingCall(client, call, "1");
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
mockPeerConn.iceConnectionState = "disconnected";
mockPeerConn.iceConnectionStateChangeListener!();
jest.spyOn(mockPeerConn, "restartIce");
});
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
jest.advanceTimersByTime(3 * 1000);
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should hang up after being disconnected for 30 seconds", () => {
@@ -1602,6 +1665,20 @@ describe("Call", function () {
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should restart ICE gathering once again after ICE being failed", () => {
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
// @ts-ignore
mockPeerConn.restartIce = null;
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should not hangup if we've managed to re-connect", () => {
mockPeerConn.iceConnectionState = "connected";
mockPeerConn.iceConnectionStateChangeListener!();
@@ -1609,4 +1686,130 @@ describe("Call", function () {
expect(call.hangup).not.toHaveBeenCalled();
});
});
describe("Call replace", () => {
it("Fires event when call replaced", async () => {
const onReplace = jest.fn();
call.on(CallEvent.Replaced, onReplace);
await call.placeVoiceCall();
const call2 = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
});
call2.on(CallEvent.Error, errorListener);
await fakeIncomingCall(client, call2);
call.replacedBy(call2);
expect(onReplace).toHaveBeenCalled();
});
});
describe("should handle glare in negotiation process", () => {
beforeEach(async () => {
// cut methods not want to test
call.hangup = () => null;
call.isLocalOnHold = () => true;
// @ts-ignore
call.updateRemoteSDPStreamMetadata = jest.fn();
// @ts-ignore
call.getRidOfRTXCodecs = jest.fn();
// @ts-ignore
call.createAnswer = jest.fn().mockResolvedValue({});
// @ts-ignore
call.sendVoipEvent = jest.fn();
});
it("and reject remote offer if not polite and have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do have pending answer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have not a local offer
// @ts-ignore
call.makingOffer = false;
// If we have a setRemoteDescription() answer operation pending, then
// we will be "stable" by the time the next setRemoteDescription() is
// executed, so we count this being readyForOffer when deciding whether to
// ignore the offer.
// @ts-ignore
call.isSettingRemoteAnswerPending = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have no local offer
// @ts-ignore
call.makingOffer = false;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "stable",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and if polite do rollback pending local offer", async () => {
// polite user == CallDirection.Inbound
call.direction = CallDirection.Inbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
});
});
+1 -1
View File
@@ -102,7 +102,7 @@ describe("CallFeed", () => {
[CallState.Connected, true],
[CallState.Connecting, false],
])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => {
call.emit(CallEvent.State, state);
call.emit(CallEvent.State, state, CallState.InviteSent, call.typed());
expect(feed.connected).toBe(expected);
});
+210 -31
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventType, GroupCallIntent, GroupCallType, MatrixCall, MatrixEvent, Room, RoomMember } from "../../../src";
import { RoomStateEvent } from "../../../src/models/room-state";
import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall";
@@ -95,16 +97,16 @@ const FAKE_STATE_EVENTS = [
},
];
const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => {
if (type === EventType.GroupCallMemberPrefix) {
return userId === undefined
? (FAKE_STATE_EVENTS as MatrixEvent[])
: (FAKE_STATE_EVENTS.find((e) => e.getStateKey() === userId) as MatrixEvent);
} else {
const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent;
return userId === undefined ? [fakeEvent] : fakeEvent;
}
};
const mockGetStateEvents =
(events: MatrixEvent[] = FAKE_STATE_EVENTS as MatrixEvent[]) =>
(type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => {
if (type === EventType.GroupCallMemberPrefix) {
return userId === undefined ? events : events.find((e) => e.getStateKey() === userId) ?? null;
} else {
const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent;
return userId === undefined ? [fakeEvent] : fakeEvent;
}
};
const ONE_HOUR = 1000 * 60 * 60;
@@ -142,6 +144,10 @@ describe("Group Call", function () {
} as unknown as RoomMember;
});
afterEach(() => {
groupCall.leave();
});
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
"throws when initializing local call feed in %s state",
async (state: GroupCallState) => {
@@ -180,10 +186,7 @@ describe("Group Call", function () {
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValue("Error");
try {
await groupCall.initLocalCallFeed();
} catch (e) {}
await expect(groupCall.initLocalCallFeed()).rejects.toBeTruthy();
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
});
@@ -382,6 +385,10 @@ describe("Group Call", function () {
call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
await groupCall.create();
const deviceCallMap = new Map<string, MatrixCall>();
deviceCallMap.set(FAKE_DEVICE_ID_1, call.typed());
(groupCall as any).calls.set(FAKE_USER_ID_1, deviceCallMap);
});
it("ignores changes, if we can't get user id of opponent", async () => {
@@ -507,8 +514,7 @@ describe("Group Call", function () {
await groupCall.setMicrophoneMuted(false);
expect(groupCall.isMicrophoneMuted()).toEqual(false);
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime + 100);
await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
expect(groupCall.isMicrophoneMuted()).toEqual(true);
});
@@ -567,7 +573,7 @@ describe("Group Call", function () {
// the call starts muted, so unmute to get in the right state to test
await groupCall.setMicrophoneMuted(false);
mockCall.localUsermediaFeed.setAudioVideoMuted.mockReset();
mocked(mockCall.localUsermediaFeed.setAudioVideoMuted).mockReset();
let metadataUpdateResolve: () => void;
const metadataUpdatePromise = new Promise<void>((resolve) => {
@@ -575,7 +581,15 @@ describe("Group Call", function () {
});
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
const getUserMediaStreamFlush = Promise.resolve("stream");
// @ts-ignore
mockCall.cleint = {
getMediaHandler: {
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
},
};
const mutePromise = groupCall.setMicrophoneMuted(true);
await getUserMediaStreamFlush;
// we should be muted at this point, before the metadata update has been sent
expect(groupCall.isMicrophoneMuted()).toEqual(true);
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
@@ -688,15 +702,15 @@ describe("Group Call", function () {
expect(client1.sendToDevice.mock.calls[0][0]).toBe("m.call.invite");
const toDeviceCallContent = client1.sendToDevice.mock.calls[0][1];
expect(Object.keys(toDeviceCallContent).length).toBe(1);
expect(Object.keys(toDeviceCallContent)[0]).toBe(FAKE_USER_ID_2);
expect(toDeviceCallContent.size).toBe(1);
expect(toDeviceCallContent.has(FAKE_USER_ID_2)).toBe(true);
const toDeviceBobDevices = toDeviceCallContent[FAKE_USER_ID_2];
expect(Object.keys(toDeviceBobDevices).length).toBe(1);
expect(Object.keys(toDeviceBobDevices)[0]).toBe(FAKE_DEVICE_ID_2);
const toDeviceBobDevices = toDeviceCallContent.get(FAKE_USER_ID_2);
expect(toDeviceBobDevices?.size).toBe(1);
expect(toDeviceBobDevices?.has(FAKE_DEVICE_ID_2)).toBe(true);
const bobDeviceMessage = toDeviceBobDevices[FAKE_DEVICE_ID_2];
expect(bobDeviceMessage.conf_id).toBe(FAKE_CONF_ID);
const bobDeviceMessage = toDeviceBobDevices?.get(FAKE_DEVICE_ID_2);
expect(bobDeviceMessage?.conf_id).toBe(FAKE_CONF_ID);
} finally {
await Promise.all([groupCall1.leave(), groupCall2.leave()]);
}
@@ -792,7 +806,7 @@ describe("Group Call", function () {
call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
call.setLocalVideoMuted = jest.fn();
call.emit(CallEvent.State, CallState.Connected);
call.emit(CallEvent.State, CallState.Connected, CallState.InviteSent, call);
expect(call.setMicrophoneMuted).toHaveBeenCalledWith(false);
expect(call.setLocalVideoMuted).toHaveBeenCalledWith(false);
@@ -811,7 +825,7 @@ describe("Group Call", function () {
mockClient = typedMockClient as unknown as MatrixClient;
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents);
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
@@ -882,14 +896,34 @@ describe("Group Call", function () {
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when no permission for audio stream", async () => {
it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
new Error("No Permission"),
);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when user media stream null", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
// @ts-ignore
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
});
it("returns false when unmuting video with no video device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
@@ -987,7 +1021,14 @@ describe("Group Call", function () {
mockClient = typedMockClient as unknown as MatrixClient;
room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2);
room.getMember = jest.fn().mockImplementation((userId) => ({ userId }));
room.currentState.members[FAKE_USER_ID_1] = {
userId: FAKE_USER_ID_1,
membership: "join",
} as unknown as RoomMember;
room.currentState.members[FAKE_USER_ID_2] = {
userId: FAKE_USER_ID_2,
membership: "join",
} as unknown as RoomMember;
groupCall = await createAndEnterGroupCall(mockClient, room);
});
@@ -1060,6 +1101,71 @@ describe("Group Call", function () {
expect(call.answerWithCallFeeds).toHaveBeenCalled();
});
const aliceEnters = () => {
room.currentState.getStateEvents = jest.fn().mockImplementation(
mockGetStateEvents([
{
getContent: () => ({
"m.calls": [
{
"m.call_id": groupCall.groupCallId,
"m.devices": [
{
device_id: FAKE_DEVICE_ID_1,
session_id: FAKE_SESSION_ID_1,
expires_ts: Date.now() + ONE_HOUR,
feeds: [],
},
],
},
],
}),
getStateKey: () => FAKE_USER_ID_1,
getRoomId: () => FAKE_ROOM_ID,
getTs: () => 0,
},
] as unknown as MatrixEvent[]),
);
room.currentState.emit(RoomStateEvent.Update, room.currentState);
};
const aliceLeaves = () => {
room.currentState.getStateEvents = jest
.fn()
.mockImplementation(mockGetStateEvents([] as unknown as MatrixEvent[]));
room.currentState.emit(RoomStateEvent.Update, room.currentState);
};
it("enables tracks on expected calls, then disables them when the participant leaves", async () => {
aliceEnters();
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed));
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
// Tracks should be enabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true);
aliceLeaves();
// Tracks should be disabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true);
});
it("disables tracks on unexpected calls, then enables them when the participant joins", async () => {
const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed));
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall);
// Tracks should be disabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true);
aliceEnters();
// Tracks should be enabled
expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true);
});
describe("handles call being replaced", () => {
let callChangedListener: jest.Mock;
let oldMockCall: MockMatrixCall;
@@ -1080,7 +1186,7 @@ describe("Group Call", function () {
});
it("handles regular case", () => {
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
@@ -1091,7 +1197,7 @@ describe("Group Call", function () {
it("handles case where call is missing from the calls map", () => {
// @ts-ignore
groupCall.calls = new Map();
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
@@ -1157,7 +1263,7 @@ describe("Group Call", function () {
userId: FAKE_USER_ID_2,
membership: "join",
} as unknown as RoomMember;
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents);
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
groupCall = await createAndEnterGroupCall(mockClient, room);
});
@@ -1547,4 +1653,77 @@ describe("Group Call", function () {
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
});
});
describe("collection stats", () => {
let groupCall: GroupCall;
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => jest.useRealTimers());
beforeEach(async () => {
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
const mockClient = typedMockClient.typed();
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
groupCall = new GroupCall(
mockClient,
room,
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
FAKE_CONF_ID,
);
});
it("should be undefined if not get stats", async () => {
// @ts-ignore
const stats = groupCall.stats;
expect(stats).toBeUndefined();
});
it("should be defined after first access", async () => {
groupCall.getGroupCallStats();
// @ts-ignore
const stats = groupCall.stats;
expect(stats).toBeDefined();
});
it("with every number should do nothing if no stats exists.", async () => {
groupCall.setGroupCallStatsInterval(0);
// @ts-ignore
let stats = groupCall.stats;
expect(stats).toBeUndefined();
groupCall.setGroupCallStatsInterval(10000);
// @ts-ignore
stats = groupCall.stats;
expect(stats).toBeUndefined();
});
it("with number should stop existing stats", async () => {
const stats = groupCall.getGroupCallStats();
// @ts-ignore
const stop = jest.spyOn(stats, "stop");
// @ts-ignore
const start = jest.spyOn(stats, "start");
groupCall.setGroupCallStatsInterval(0);
expect(stop).toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
});
it("with number should restart existing stats", async () => {
const stats = groupCall.getGroupCallStats();
// @ts-ignore
const stop = jest.spyOn(stats, "stop");
// @ts-ignore
const start = jest.spyOn(stats, "start");
groupCall.setGroupCallStatsInterval(10000);
expect(stop).toHaveBeenCalled();
expect(start).toHaveBeenCalled();
});
});
});
+38 -2
View File
@@ -98,6 +98,23 @@ describe("Group Call Event Handler", function () {
expect(groupCall.state).toBe(GroupCallState.Ended);
});
it("terminates call when redacted", async () => {
await groupCallEventHandler.start();
mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID), {
roomId: FAKE_ROOM_ID,
} as unknown as RoomState);
const groupCall = groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)!;
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, undefined, true), {
roomId: FAKE_ROOM_ID,
} as unknown as RoomState);
expect(groupCall.state).toBe(GroupCallState.Ended);
});
});
it("waits until client starts syncing", async () => {
@@ -222,9 +239,9 @@ describe("Group Call Event Handler", function () {
jest.clearAllMocks();
});
const setupCallAndStart = async (content?: IContent) => {
const setupCallAndStart = async (content?: IContent, redacted?: boolean) => {
mocked(mockRoom.currentState.getStateEvents).mockReturnValue([
makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content),
makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content, redacted),
] as unknown as MatrixEvent);
mockClient.getRooms.mockReturnValue([mockRoom]);
await groupCallEventHandler.start();
@@ -285,5 +302,24 @@ describe("Group Call Event Handler", function () {
}),
);
});
it("ignores redacted calls", async () => {
await setupCallAndStart(
{
// Real event contents to make sure that it's specifically the
// event being redacted that causes it to be ignored
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
},
true,
);
expect(mockClientEmit).not.toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
});
});
});
@@ -0,0 +1,189 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CallStatsReportGatherer } from "../../../../src/webrtc/stats/callStatsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import { MediaSsrcHandler } from "../../../../src/webrtc/stats/media/mediaSsrcHandler";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("CallStatsReportGatherer", () => {
let collector: CallStatsReportGatherer;
let rtcSpy: RTCPeerConnection;
let emitter: StatsReportEmitter;
beforeEach(() => {
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
rtcSpy.addEventListener = jest.fn();
emitter = new StatsReportEmitter();
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
describe("on process stats", () => {
it("if active calculate stats reports", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
const report = {} as RTCStatsReport;
report.forEach = jest.fn().mockReturnValue([]);
getStats.mockResolvedValue(report);
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(actual).toEqual({
isFirstCollection: true,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(collector.getActive()).toBeTruthy();
});
it("if not active do not calculate stats reports", async () => {
collector.setActive(false);
const getStats = jest.spyOn(rtcSpy, "getStats");
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).not.toHaveBeenCalled();
});
it("if get reports fails, the collector becomes inactive", async () => {
expect(collector.getActive()).toBeTruthy();
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockRejectedValue(new Error("unknown"));
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active and getStats returns not an RTCStatsReport inside a promise the collector fails and becomes inactive", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
getStats.mockReturnValue({});
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual({
isFirstCollection: true,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active and the collector runs not the first time the Summery Stats is marked as not fits collection", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
collector.previousStatsReport = {} as RTCStatsReport;
const report = {} as RTCStatsReport;
report.forEach = jest.fn().mockReturnValue([]);
getStats.mockResolvedValue(report);
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(actual).toEqual({
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(collector.getActive()).toBeTruthy();
});
});
describe("on signal state change event", () => {
let events: { [key: string]: any };
beforeEach(() => {
events = [];
// Define the addEventListener method with a Jest mock function
rtcSpy.addEventListener = jest.fn((event: any, callback: any) => {
events[event] = callback;
});
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
it("in case of stable, parse remote and local description", () => {
// @ts-ignore
const mediaSsrcHandler = {
parse: jest.fn(),
ssrcToMid: jest.fn(),
findMidBySsrc: jest.fn(),
getSsrcToMidMap: jest.fn(),
} as MediaSsrcHandler;
const remoteSDP = "sdp";
const localSDP = "sdp";
// @ts-ignore
rtcSpy.signalingState = "stable";
// @ts-ignore
rtcSpy.currentRemoteDescription = <RTCSessionDescription>{ sdp: remoteSDP };
// @ts-ignore
rtcSpy.currentLocalDescription = <RTCSessionDescription>{ sdp: localSDP };
// @ts-ignore
collector.trackStats.mediaSsrcHandler = mediaSsrcHandler;
events["signalingstatechange"]();
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(remoteSDP, "remote");
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(localSDP, "local");
});
});
});
@@ -0,0 +1,129 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackID } from "../../../../src/webrtc/stats/statsReport";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
import { ConnectionStatsReportBuilder } from "../../../../src/webrtc/stats/connectionStatsReportBuilder";
describe("StatsReportBuilder", () => {
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
const LOCAL_AUDIO_TRACK_ID = "LOCAL_AUDIO_TRACK_ID";
const REMOTE_AUDIO_TRACK_ID = "REMOTE_AUDIO_TRACK_ID";
const REMOTE_VIDEO_TRACK_ID = "REMOTE_VIDEO_TRACK_ID";
const localAudioTrack = new MediaTrackStats(LOCAL_AUDIO_TRACK_ID, "local", "audio");
const localVideoTrack = new MediaTrackStats(LOCAL_VIDEO_TRACK_ID, "local", "video");
const remoteAudioTrack = new MediaTrackStats(REMOTE_AUDIO_TRACK_ID, "remote", "audio");
const remoteVideoTrack = new MediaTrackStats(REMOTE_VIDEO_TRACK_ID, "remote", "video");
const stats = new Map<TrackID, MediaTrackStats>([
[LOCAL_AUDIO_TRACK_ID, localAudioTrack],
[LOCAL_VIDEO_TRACK_ID, localVideoTrack],
[REMOTE_AUDIO_TRACK_ID, remoteAudioTrack],
[REMOTE_VIDEO_TRACK_ID, remoteVideoTrack],
]);
beforeEach(() => {
buildData();
});
describe("should build stats", () => {
it("by media track stats.", async () => {
expect(ConnectionStatsReportBuilder.build(stats)).toEqual({
bitrate: {
audio: {
download: 4000,
upload: 5000,
},
download: 5004000,
upload: 3005000,
video: {
download: 5000000,
upload: 3000000,
},
},
codec: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", "opus"],
["LOCAL_VIDEO_TRACK_ID", "v8"],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", "opus"],
["REMOTE_VIDEO_TRACK_ID", "v9"],
]),
},
framerate: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", 0],
["LOCAL_VIDEO_TRACK_ID", 30],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", 0],
["REMOTE_VIDEO_TRACK_ID", 60],
]),
},
packetLoss: {
download: 7,
total: 15,
upload: 28,
},
resolution: {
local: new Map([
["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }],
["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }],
]),
remote: new Map([
["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }],
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
]),
},
jitter: new Map([
["REMOTE_AUDIO_TRACK_ID", 0.1],
["REMOTE_VIDEO_TRACK_ID", 50],
]),
audioConcealment: new Map([
["REMOTE_AUDIO_TRACK_ID", { concealedAudio: 3000, totalAudioDuration: 3000 * 20 }],
]),
totalAudioConcealment: {
concealedAudio: 3000,
totalAudioDuration: (1 / 0.05) * 3000,
},
});
});
});
const buildData = (): void => {
localAudioTrack.setCodec("opus");
localAudioTrack.setLoss({ packetsTotal: 10, packetsLost: 5, isDownloadStream: false });
localAudioTrack.setBitrate({ download: 0, upload: 5000 });
remoteAudioTrack.setCodec("opus");
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
remoteAudioTrack.setJitter(0.1);
remoteAudioTrack.setAudioConcealment(3000, 3000 * 20);
localVideoTrack.setCodec("v8");
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
localVideoTrack.setBitrate({ download: 0, upload: 3000000 });
localVideoTrack.setFramerate(30);
localVideoTrack.setResolution({ width: 780, height: 460 });
remoteVideoTrack.setCodec("v9");
remoteVideoTrack.setLoss({ packetsTotal: 40, packetsLost: 4, isDownloadStream: true });
remoteVideoTrack.setBitrate({ download: 5000000, upload: 0 });
remoteVideoTrack.setFramerate(60);
remoteVideoTrack.setResolution({ width: 1080, height: 960 });
remoteVideoTrack.setJitter(50);
};
});
@@ -0,0 +1,46 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ConnectionStatsBuilder } from "../../../../src/webrtc/stats/connectionStatsBuilder";
describe("ConnectionStatsReporter", () => {
describe("should on bandwidth stats", () => {
it("build bandwidth report if chromium starts attributes available", () => {
const stats = {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
describe("should on connection stats", () => {
it("build bandwidth report if chromium starts attributes available", () => {
const stats = {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
});
@@ -0,0 +1,170 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
import { CallStatsReportSummary } from "../../../../src/webrtc/stats/callStatsReportSummary";
const GROUP_CALL_ID = "GROUP_ID";
const LOCAL_USER_ID = "LOCAL_USER_ID";
const TIME_INTERVAL = 10000;
describe("GroupCallStats", () => {
let stats: GroupCallStats;
beforeEach(() => {
stats = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, TIME_INTERVAL);
});
describe("should on adding a stats collector", () => {
it("creating a new one if not existing.", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
});
it("creating only one when trying add the same collector multiple times.", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeFalsy();
// The User ID is not relevant! Because for stats the call is needed and the user id is for monitoring
expect(stats.addStatsReportGatherer("CALL_ID", "SOME_OTHER_USER_ID", mockRTCPeerConnection())).toBeFalsy();
});
});
describe("should on removing a stats collector", () => {
it("returning `true` if the collector exists", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.removeStatsReportGatherer("CALL_ID")).toBeTruthy();
});
it("returning false if the collector not exists", async () => {
expect(stats.removeStatsReportGatherer("CALL_ID_NOT_EXIST")).toBeFalsy();
});
});
describe("should on get stats collector", () => {
it("returning `undefined` if collector not existing", async () => {
expect(stats.getStatsReportGatherer("CALL_ID")).toBeUndefined();
});
it("returning Collector if collector existing", async () => {
expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy();
expect(stats.getStatsReportGatherer("CALL_ID")).toBeDefined();
});
});
describe("should on start", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("starting processing stats as well without stats collectors", async () => {
// @ts-ignore
stats.processStats = jest.fn();
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
// @ts-ignore
expect(stats.processStats).toHaveBeenCalled();
});
it("not starting processing stats if interval 0", async () => {
const statsDisabled = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, 0);
// @ts-ignore
statsDisabled.processStats = jest.fn();
statsDisabled.start();
jest.advanceTimersByTime(TIME_INTERVAL);
// @ts-ignore
expect(statsDisabled.processStats).not.toHaveBeenCalled();
});
it("starting processing and calling the collectors", async () => {
stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection());
const collector = stats.getStatsReportGatherer("CALL_ID");
stats.reports.emitSummaryStatsReport = jest.fn();
const summaryStats = {
isFirstCollection: true,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
} as CallStatsReportSummary;
let processStatsSpy;
if (collector) {
processStatsSpy = jest.spyOn(collector, "processStats").mockResolvedValue(summaryStats);
stats.start();
jest.advanceTimersByTime(TIME_INTERVAL);
} else {
throw new Error("Test failed, because no Collector found!");
}
expect(processStatsSpy).toHaveBeenCalledWith(GROUP_CALL_ID, LOCAL_USER_ID);
});
it("doing nothing if process already running", async () => {
// @ts-ignore
jest.spyOn(global, "setInterval").mockReturnValue(22);
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
stats.start();
stats.start();
stats.start();
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
});
});
describe("should on stop", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("finish stats process if was started", async () => {
// @ts-ignore
jest.spyOn(global, "setInterval").mockReturnValue(22);
jest.spyOn(global, "clearInterval");
stats.start();
expect(setInterval).toHaveBeenCalledTimes(1);
stats.stop();
expect(clearInterval).toHaveBeenCalledWith(22);
});
it("do nothing if stats process was not started", async () => {
jest.spyOn(global, "clearInterval");
stats.stop();
expect(clearInterval).not.toHaveBeenCalled();
});
});
});
const mockRTCPeerConnection = (): RTCPeerConnection => {
const pc = {} as RTCPeerConnection;
pc.addEventListener = jest.fn();
pc.getStats = jest.fn().mockResolvedValue(null);
return pc;
};
@@ -0,0 +1,41 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mid, Ssrc, MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler";
import { REMOTE_SFU_DESCRIPTION } from "../../../../test-utils/webrtc";
describe("MediaSsrcHandler", () => {
const remoteMap = new Map<Mid, Ssrc[]>([
["0", ["2963372119"]],
["1", ["1212931603"]],
]);
let handler: MediaSsrcHandler;
beforeEach(() => {
handler = new MediaSsrcHandler();
});
describe("should parse description", () => {
it("and build mid ssrc map", async () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.getSsrcToMidMap("remote")).toEqual(remoteMap);
});
});
describe("should on find mid by ssrc", () => {
it("and return mid if mapping exists.", async () => {
handler.parse(REMOTE_SFU_DESCRIPTION, "remote");
expect(handler.findMidBySsrc("2963372119", "remote")).toEqual("0");
});
});
});
@@ -0,0 +1,127 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler";
describe("TrackHandler", () => {
let pc: RTCPeerConnection;
let handler: MediaTrackHandler;
beforeEach(() => {
pc = {
getTransceivers: (): RTCRtpTransceiver[] => [mockTransceiver("1", "audio"), mockTransceiver("2", "video")],
} as RTCPeerConnection;
handler = new MediaTrackHandler(pc);
});
describe("should get local tracks", () => {
it("returns video track", async () => {
expect(handler.getLocalTracks("video")).toEqual([
{
id: `sender-track-2`,
kind: "video",
} as MediaStreamTrack,
]);
});
it("returns audio track", async () => {
expect(handler.getLocalTracks("audio")).toEqual([
{
id: `sender-track-1`,
kind: "audio",
} as MediaStreamTrack,
]);
});
});
describe("should get local track by mid", () => {
it("returns video track", async () => {
expect(handler.getLocalTrackIdByMid("2")).toEqual("sender-track-2");
});
it("returns audio track", async () => {
expect(handler.getLocalTrackIdByMid("1")).toEqual("sender-track-1");
});
it("returns undefined if not exists", async () => {
expect(handler.getLocalTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get remote track by mid", () => {
it("returns video track", async () => {
expect(handler.getRemoteTrackIdByMid("2")).toEqual("receiver-track-2");
});
it("returns audio track", async () => {
expect(handler.getRemoteTrackIdByMid("1")).toEqual("receiver-track-1");
});
it("returns undefined if not exists", async () => {
expect(handler.getRemoteTrackIdByMid("3")).toBeUndefined();
});
});
describe("should get track by id", () => {
it("returns remote track", async () => {
expect(handler.getTackById("receiver-track-2")).toEqual({
id: `receiver-track-2`,
kind: "video",
} as MediaStreamTrack);
});
it("returns local track", async () => {
expect(handler.getTackById("sender-track-1")).toEqual({
id: `sender-track-1`,
kind: "audio",
} as MediaStreamTrack);
});
it("returns undefined if not exists", async () => {
expect(handler.getTackById("sender-track-3")).toBeUndefined();
});
});
describe("should get simulcast track count", () => {
it("returns 2", async () => {
expect(handler.getActiveSimulcastStreams()).toEqual(3);
});
});
describe("should get Transceiver by Track ID", () => {
it("and returns remote Transceiver if exits", async () => {
expect(handler.getTransceiverByTrackId(`receiver-track-1`)?.mid).toEqual("1");
});
it("and returns local Transceiver if exits", async () => {
expect(handler.getTransceiverByTrackId(`sender-track-2`)?.mid).toEqual("2");
});
it("returns undefined if Transceiver not exits", async () => {
expect(handler.getTransceiverByTrackId("22")).toBeUndefined();
});
});
});
const mockTransceiver = (mid: string, kind: "video" | "audio"): RTCRtpTransceiver => {
return {
mid,
currentDirection: "sendrecv",
sender: {
track: { id: `sender-track-${mid}`, kind } as MediaStreamTrack,
} as RTCRtpSender,
receiver: {
track: { id: `receiver-track-${mid}`, kind } as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
};
@@ -0,0 +1,96 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler";
import { MediaTrackStatsHandler } from "../../../../../src/webrtc/stats/media/mediaTrackStatsHandler";
import { MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler";
describe("MediaTrackStatsHandler", () => {
let statsHandler: MediaTrackStatsHandler;
let ssrcHandler: MediaSsrcHandler;
let trackHandler: MediaTrackHandler;
beforeEach(() => {
ssrcHandler = {} as MediaSsrcHandler;
trackHandler = {} as MediaTrackHandler;
trackHandler.getLocalTrackIdByMid = jest.fn().mockReturnValue("2222");
trackHandler.getRemoteTrackIdByMid = jest.fn().mockReturnValue("5555");
trackHandler.getLocalTracks = jest.fn().mockReturnValue([{ id: "2222" } as MediaStreamTrack]);
trackHandler.getTackById = jest.fn().mockReturnValue([{ id: "2222", kind: "audio" } as MediaStreamTrack]);
statsHandler = new MediaTrackStatsHandler(ssrcHandler, trackHandler);
});
describe("should find track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", () => {
const report = { trackIdentifier: "123" };
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("123");
});
it("and returns stats if `mid` exists in report", () => {
const reportIn = { mid: "1", type: "inbound-rtp" };
expect(statsHandler.findTrack2Stats(reportIn, "remote")?.trackId).toEqual("5555");
const reportOut = { mid: "1", type: "outbound-rtp" };
expect(statsHandler.findTrack2Stats(reportOut, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if `ssrc` exists in report but not on connection", () => {
const report = { ssrc: "142443", type: "inbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue(undefined);
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toBeUndefined();
});
it("and returns undefined if `ssrc` exists in inbound-rtp report", () => {
const report = { ssrc: "142443", type: "inbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("5555");
});
it("and returns undefined if `ssrc` exists in outbound-rtp report", () => {
const report = { ssrc: "142443", type: "outbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if needed property not existing", () => {
const report = {};
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
});
});
describe("should find local video track stats", () => {
it("and returns stats if `trackIdentifier` exists in report", async () => {
const report = { trackIdentifier: "2222" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
it("and returns stats if `mid` exists in report", () => {
const report = { mid: "1" };
expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222");
});
it("and returns undefined if `ssrc` exists", () => {
const report = { ssrc: "142443", type: "outbound-rtp" };
ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2");
expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222");
});
it("and returns undefined if needed property not existing", async () => {
const report = {};
expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined();
});
});
describe("should find a Transceiver by Track id", () => {
it("and returns undefined if Transceiver not existing", async () => {
trackHandler.getTransceiverByTrackId = jest.fn().mockReturnValue(undefined);
expect(statsHandler.findTransceiverByTrackId("12")).toBeUndefined();
});
it("and returns Transceiver if existing", async () => {
const ts = {} as RTCRtpTransceiver;
trackHandler.getTransceiverByTrackId = jest.fn().mockReturnValue(ts);
expect(statsHandler.findTransceiverByTrackId("12")).toEqual(ts);
});
});
});
@@ -0,0 +1,65 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import {
ByteSentStatsReport,
ConnectionStatsReport,
StatsReport,
SummaryStatsReport,
} from "../../../../src/webrtc/stats/statsReport";
describe("StatsReportEmitter", () => {
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
});
it("should emit and receive ByteSendStatsReport", async () => {
const report = {} as ByteSentStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.BYTE_SENT_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitByteSendReport(report);
});
});
it("should emit and receive ConnectionStatsReport", async () => {
const report = {} as ConnectionStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.CONNECTION_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitConnectionStatsReport(report);
});
});
it("should emit and receive SummaryStatsReport", async () => {
const report = {} as SummaryStatsReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.SUMMARY_STATS, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitSummaryStatsReport(report);
});
});
});
@@ -0,0 +1,592 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SummaryStatsReportGatherer } from "../../../../src/webrtc/stats/summaryStatsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
describe("SummaryStatsReportGatherer", () => {
let reporter: SummaryStatsReportGatherer;
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
emitter.emitSummaryStatsReport = jest.fn();
reporter = new SummaryStatsReportGatherer(emitter);
});
describe("build Summary Stats Report", () => {
it("should do nothing if summary list empty", async () => {
reporter.build([]);
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
});
it("should do nothing if a summary stats element collection the is first time", async () => {
reporter.build([
{
isFirstCollection: true,
receivedMedia: 10,
receivedAudioMedia: 4,
receivedVideoMedia: 6,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
]);
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
});
it("should trigger new summary report", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 4,
receivedVideoMedia: 6,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 13,
receivedAudioMedia: 0,
receivedVideoMedia: 13,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 5,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 10,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 15,
receivedAudioMedia: 6,
receivedVideoMedia: 9,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0.5,
percentageReceivedAudioMedia: 0.5,
percentageReceivedVideoMedia: 0.75,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 4,
percentageConcealedAudio: 0.0375,
});
});
it("as received video Media, although video was not received, but because video muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no video Media, because only on video was muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 2,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 0,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no audio Media, although audio not received and audio muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 100,
receivedAudioMedia: 0,
receivedVideoMedia: 100,
audioTrackSummary: {
count: 1,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 0,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("should find max jitter and max packet loss", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 20,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 40,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 20,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
});
});
it("as received video Media, if no audio track received should count as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 0,
receivedVideoMedia: 10,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received audio Media, if no video track received should count as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 22,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no media at all, as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("should filter the first time summery stats", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: true,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 20,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 40,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 2,
maxPacketLoss: 40,
peerConnections: 3,
percentageConcealedAudio: 0,
});
});
});
});
@@ -0,0 +1,405 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackStatsBuilder } from "../../../../src/webrtc/stats/trackStatsBuilder";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
describe("TrackStatsBuilder", () => {
describe("should on frame and resolution stats", () => {
it("creating empty frame and resolution report, if no data available.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsBuilder.buildFramerateResolution(trackStats, {});
expect(trackStats.getFramerate()).toEqual(0);
expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 });
});
it("creating empty frame and resolution report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildFramerateResolution(trackStats, {
framesPerSecond: 22.2,
frameHeight: 180,
frameWidth: 360,
});
expect(trackStats.getFramerate()).toEqual(22);
expect(trackStats.getResolution()).toEqual({ width: 360, height: 180 });
});
});
describe("should on simulcast", () => {
it("creating simulcast framerate.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsBuilder.calculateSimulcastFramerate(
trackStats,
{
framesSent: 100,
timestamp: 1678957001000,
},
{
framesSent: 10,
timestamp: 1678957000000,
},
3,
);
expect(trackStats.getFramerate()).toEqual(30);
});
});
describe("should on bytes received stats", () => {
it("creating build bitrate received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildBitrateReceived(
trackStats,
{
bytesReceived: 2001000,
timestamp: 1678957010,
},
{ bytesReceived: 2000000, timestamp: 1678957000 },
);
expect(trackStats.getBitrate()).toEqual({ download: 800, upload: 0 });
});
});
describe("should on bytes send stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsBuilder.buildBitrateSend(
trackStats,
{
bytesSent: 2001000,
timestamp: 1678957010,
},
{ bytesSent: 2000000, timestamp: 1678957000 },
);
expect(trackStats.getBitrate()).toEqual({ download: 0, upload: 800 });
});
});
describe("should on codec stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const remote = {} as RTCStatsReport;
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
TrackStatsBuilder.buildCodec(remote, trackStats, { codecId: "codecID" });
expect(trackStats.getCodec()).toEqual("v8");
});
});
describe("should on package lost stats", () => {
it("creating build package lost on send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsBuilder.buildPacketsLost(
trackStats,
{
type: "outbound-rtp",
packetsSent: 200,
packetsLost: 120,
},
{
packetsSent: 100,
packetsLost: 30,
},
);
expect(trackStats.getLoss()).toEqual({ packetsTotal: 190, packetsLost: 90, isDownloadStream: false });
});
it("creating build package lost on received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildPacketsLost(
trackStats,
{
type: "inbound-rtp",
packetsReceived: 300,
packetsLost: 100,
},
{
packetsReceived: 100,
packetsLost: 20,
},
);
expect(trackStats.getLoss()).toEqual({ packetsTotal: 280, packetsLost: 80, isDownloadStream: true });
});
});
describe("should set state of a TrackStats", () => {
it("to not alive if Transceiver undefined", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.setTrackStatsState(trackStats, undefined);
expect(trackStats.alive).toBeFalsy();
});
it("to not alive if Transceiver has no local track", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
const ts = {
sender: {
track: null,
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
it("to alive if Transceiver remote and track is alive", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
trackStats.alive = false;
const ts = {
receiver: {
track: {
readyState: "live",
enabled: false,
muted: false,
} as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
it("to alive if Transceiver local and track is live", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
trackStats.alive = false;
const ts = {
sender: {
track: {
readyState: "live",
enabled: false,
muted: false,
} as MediaStreamTrack,
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
it("to not alive if Transceiver track is ended", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const ts = {
receiver: {
track: {
readyState: "ended",
enabled: false,
muted: false,
} as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
it("to not alive and muted if Transceiver track is live and muted", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const ts = {
receiver: {
track: {
readyState: "live",
enabled: false,
muted: true,
} as MediaStreamTrack,
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
expect(trackStats.muted).toBeTruthy();
});
});
describe("should build Track Summary", () => {
it("and returns empty summary if stats list empty", async () => {
const summary = TrackStatsBuilder.buildTrackSummary([]);
expect(summary).toEqual({
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary if stats list not empty and ignore local summery", async () => {
const trackStatsList = buildMockTrackStatsList();
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and count muted if alive", async () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[5].muted = true;
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and ignore muted if not alive", async () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[1].alive = false;
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and build max jitter, packet loss and audio conealment", async () => {
const trackStatsList = buildMockTrackStatsList();
// video remote
trackStatsList[1].setJitter(12);
trackStatsList[4].setJitter(66);
trackStatsList[6].setJitter(1);
trackStatsList[1].setLoss({ packetsLost: 55, packetsTotal: 0, isDownloadStream: true });
trackStatsList[4].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
trackStatsList[6].setLoss({ packetsLost: 1, packetsTotal: 0, isDownloadStream: true });
// audio remote
trackStatsList[2].setJitter(1);
trackStatsList[5].setJitter(15);
trackStatsList[2].setLoss({ packetsLost: 5, packetsTotal: 0, isDownloadStream: true });
trackStatsList[5].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
trackStatsList[2].setAudioConcealment(220, 2000);
trackStatsList[5].setAudioConcealment(180, 2000);
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 15,
maxPacketLoss: 5,
concealedAudio: 400,
totalAudio: 4000,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 66,
maxPacketLoss: 55,
concealedAudio: 0,
totalAudio: 0,
},
});
});
});
describe("should build jitter value in Track Stats", () => {
it("and returns track stats without jitter if report not 'inbound-rtp'", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildJitter(trackStats, { jitter: 0.01 });
expect(trackStats.getJitter()).toEqual(0);
});
it("and returns track stats with jitter", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
expect(trackStats.getJitter()).toEqual(10);
});
it("and returns negative jitter if stats has no jitter value", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp" });
expect(trackStats.getJitter()).toEqual(-1);
});
it("and returns jitter as number", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
expect(trackStats.getJitter()).toEqual(500);
});
});
});
function buildMockTrackStatsList(): MediaTrackStats[] {
const trackStats1 = new MediaTrackStats("1", "local", "video");
trackStats1.muted = false;
trackStats1.alive = true;
const trackStats2 = new MediaTrackStats("1", "remote", "video");
trackStats2.muted = false;
trackStats2.alive = true;
const trackStats3 = new MediaTrackStats("1", "remote", "audio");
trackStats3.muted = false;
trackStats3.alive = true;
const trackStats4 = new MediaTrackStats("1", "local", "audio");
trackStats4.muted = false;
trackStats4.alive = true;
const trackStats5 = new MediaTrackStats("1", "remote", "video");
trackStats5.muted = false;
trackStats5.alive = true;
const trackStats6 = new MediaTrackStats("1", "remote", "audio");
trackStats6.muted = false;
trackStats6.alive = true;
const trackStats7 = new MediaTrackStats("1", "remote", "video");
trackStats7.muted = false;
trackStats7.alive = true;
return [trackStats1, trackStats2, trackStats3, trackStats4, trackStats5, trackStats6, trackStats7];
}
@@ -0,0 +1,126 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TransportStatsBuilder } from "../../../../src/webrtc/stats/transportStatsBuilder";
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
describe("TransportStatsReporter", () => {
describe("should on build report", () => {
const REMOTE_CANDIDATE_ID = "REMOTE_CANDIDATE_ID";
const LOCAL_CANDIDATE_ID = "LOCAL_CANDIDATE_ID";
const localIC = { ip: "88.88.99.1", port: 56670, protocol: "tcp", candidateType: "local", networkType: "lan" };
const remoteIC = {
ip: "123.88.99.1",
port: 46670,
protocol: "udp",
candidateType: "srfx",
networkType: "wifi",
};
const isFocus = false;
const rtt = 200000;
it("build new transport stats if all properties there", () => {
const { report, stats } = mockStatsReport(isFocus, 0);
const conferenceStatsTransport: TransportStats[] = [];
const transportStats = TransportStatsBuilder.buildReport(report, stats, conferenceStatsTransport, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
it("build next transport stats if candidates different", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 1);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
{
ip: `${remoteIC.ip + 1}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 1}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
it("build not a second transport stats if candidates the same", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 0);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
type: remoteIC.protocol,
localIp: `${localIC.ip + 0}:${localIC.port}`,
isFocus,
localCandidateType: localIC.candidateType,
remoteCandidateType: remoteIC.candidateType,
networkType: localIC.networkType,
rtt,
},
]);
});
const mockStatsReport = (
isFocus: boolean,
prifix: number,
): { report: RTCStatsReport; stats: RTCIceCandidatePairStats } => {
const report = {} as RTCStatsReport;
report.get = (key: string) => {
if (key === LOCAL_CANDIDATE_ID) {
return { ...localIC, ip: localIC.ip + prifix };
}
if (key === REMOTE_CANDIDATE_ID) {
return { ...remoteIC, ip: remoteIC.ip + prifix };
}
// remote
return {};
};
const stats = {
remoteCandidateId: REMOTE_CANDIDATE_ID,
localCandidateId: LOCAL_CANDIDATE_ID,
currentRoundTripTime: 200,
} as RTCIceCandidatePairStats;
return { report, stats };
};
});
});
@@ -0,0 +1,28 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ValueFormatter } from "../../../../src/webrtc/stats/valueFormatter";
describe("ValueFormatter", () => {
describe("on get non negative values", () => {
it("formatter shod return number", async () => {
expect(ValueFormatter.getNonNegativeValue("2")).toEqual(2);
expect(ValueFormatter.getNonNegativeValue(0)).toEqual(0);
expect(ValueFormatter.getNonNegativeValue("-2")).toEqual(0);
expect(ValueFormatter.getNonNegativeValue("")).toEqual(0);
expect(ValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
});
});
});
+9
View File
@@ -63,6 +63,7 @@ export function isDmMemberCountCondition(condition: AnyMemberCountCondition): bo
export enum ConditionKind {
EventMatch = "event_match",
EventPropertyIs = "event_property_is",
EventPropertyContains = "event_property_contains",
ContainsDisplayName = "contains_display_name",
RoomMemberCount = "room_member_count",
SenderNotificationPermission = "sender_notification_permission",
@@ -88,6 +89,11 @@ export interface IEventPropertyIsCondition extends IPushRuleCondition<ConditionK
value: string | boolean | null | number;
}
export interface IEventPropertyContainsCondition extends IPushRuleCondition<ConditionKind.EventPropertyContains> {
key: string;
value: string | boolean | null | number;
}
export interface IContainsDisplayNameCondition extends IPushRuleCondition<ConditionKind.ContainsDisplayName> {
// no additional fields
}
@@ -114,6 +120,7 @@ export interface ICallStartedPrefixCondition extends IPushRuleCondition<Conditio
export type PushRuleCondition =
| IEventMatchCondition
| IEventPropertyIsCondition
| IEventPropertyContainsCondition
| IContainsDisplayNameCondition
| IRoomMemberCountCondition
| ISenderNotificationPermissionCondition
@@ -130,6 +137,8 @@ export enum PushRuleKind {
export enum RuleId {
Master = ".m.rule.master",
IsUserMention = ".org.matrix.msc3952.is_user_mention",
IsRoomMention = ".org.matrix.msc3952.is_room_mention",
ContainsDisplayName = ".m.rule.contains_display_name",
ContainsUserName = ".m.rule.contains_user_name",
AtRoomNotification = ".m.rule.roomnotif",
+6
View File
@@ -112,6 +112,12 @@ export interface LoginTokenPostResponse {
login_token: string;
/**
* Expiration in seconds.
*
* @deprecated this is only provided for compatibility with original revision of the MSC.
*/
expires_in: number;
/**
* Expiration in milliseconds.
*/
expires_in_ms: number;
}
+7
View File
@@ -235,6 +235,13 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
"org.matrix.msc3890.local_notification_settings",
);
/**
* https://github.com/matrix-org/matrix-doc/pull/4023
*
* @experimental
*/
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
export interface IEncryptedFile {
url: string;
mimetype?: string;
+3 -6
View File
@@ -38,7 +38,7 @@ export interface CachedReceipt {
data: Receipt;
}
export type ReceiptCache = { [eventId: string]: CachedReceipt[] };
export type ReceiptCache = Map<string, CachedReceipt[]>;
export interface ReceiptContent {
[eventId: string]: {
@@ -49,11 +49,8 @@ export interface ReceiptContent {
}
// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
export type Receipts = {
[receiptType: string]: {
[userId: string]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
// map: receipt type → user Id → receipt
export type Receipts = Map<string, Map<string, [real: WrappedReceipt | null, synthetic: WrappedReceipt | null]>>;
export type CachedReceiptStructure = {
eventId: string;
+1
View File
@@ -186,6 +186,7 @@ export interface IRelationsRequestOpts {
to?: string;
limit?: number;
dir?: Direction;
recurse?: boolean; // MSC3981 Relations Recursion https://github.com/matrix-org/matrix-spec-proposals/pull/3981
}
export interface IRelationsResponse {
+2 -2
View File
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAuthData } from "../interactive-auth";
import { IAuthDict, IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: IAuthData;
auth?: IAuthDict;
};
/**
+3 -1
View File
@@ -25,7 +25,7 @@ export class ReEmitter {
public constructor(private readonly target: EventEmitter) {}
// Map from emitter to event name to re-emitter
private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
private reEmitters = new WeakMap<EventEmitter, Map<string, (...args: any[]) => void>>();
public reEmit(source: EventEmitter, eventNames: string[]): void {
let reEmittersByEvent = this.reEmitters.get(source);
@@ -35,6 +35,8 @@ export class ReEmitter {
}
for (const eventName of eventNames) {
if (reEmittersByEvent.has(eventName)) continue;
// We include the source as the last argument for event handlers which may need it,
// such as read receipt listeners on the client class which won't have the context
// of the room.
+3 -5
View File
@@ -21,6 +21,7 @@ import { MatrixError } from "./http-api";
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
import { MatrixScheduler } from "./scheduler";
import { SyncState } from "./sync";
import { MapWithDefault } from "./utils";
const MAX_BATCH_SIZE = 20;
@@ -122,12 +123,9 @@ export class ToDeviceMessageQueue {
* Attempts to send a batch of to-device messages.
*/
private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> {
const contentMap: Record<string, Record<string, ToDevicePayload>> = {};
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
for (const item of batch.batch) {
if (!contentMap[item.userId]) {
contentMap[item.userId] = {};
}
contentMap[item.userId][item.deviceId] = item.payload;
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
}
logger.info(
+3 -3
View File
@@ -142,7 +142,7 @@ export class AutoDiscovery {
},
};
if (!wellknown || !wellknown["m.homeserver"]) {
if (!wellknown?.["m.homeserver"]) {
logger.error("No m.homeserver key in config");
clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
@@ -171,7 +171,7 @@ export class AutoDiscovery {
// Step 3: Make sure the homeserver URL points to a homeserver.
const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`);
if (!hsVersions || !hsVersions.raw?.["versions"]) {
if (!hsVersions?.raw?.["versions"]) {
logger.error("Invalid /versions response");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
@@ -345,7 +345,7 @@ export class AutoDiscovery {
const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
if (!response) return {};
return response.raw || {};
return response.raw ?? {};
}
/**
+323 -161
View File
File diff suppressed because it is too large Load Diff
+40 -46
View File
@@ -14,31 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
import type { IToDeviceEvent } from "../sync-accumulator";
import type { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { CryptoApi } from "../crypto-api";
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
import { IEncryptedEventInfo } from "../crypto/api";
import { IEventDecryptionResult } from "../@types/crypto";
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
/**
* Common interface for the crypto implementations
*/
export interface CryptoBackend extends SyncCryptoCallbacks {
/**
* Global override for whether the client should ever send encrypted
* messages to unverified devices. This provides the default for rooms which
* do not specify a value.
*
* If true, all unverified devices will be blacklisted by default
*/
globalBlacklistUnverifiedDevices: boolean;
export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
/**
* Whether sendMessage in a room with unknown and unverified devices
* should throw an error and not send the message. This has 'Global' for
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
* no room-level equivalent for this setting.
*
* @remarks this is here, rather than in `CryptoApi`, because I don't think we're
* going to support it in the rust crypto implementation.
*/
globalErrorOnUnknownDevices: boolean;
@@ -47,16 +43,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
stop(): void;
/**
* Checks if the user has previously published cross-signing keys
*
* This means downloading the devicelist for the user and checking if the list includes
* the cross-signing pseudo-device.
* @returns true if the user has previously published cross-signing keys
*/
userHasCrossSigningKeys(): Promise<boolean>;
/**
* Get the verification level for a given user
*
@@ -66,24 +52,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
*/
checkUserTrust(userId: string): UserTrustLevel;
/**
* Get the verification level for a given device
*
* TODO: define this better
*
* @param userId - user to be checked
* @param deviceId - device to be checked
*/
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
*
* @param room - the room the event is in
*/
prepareToEncrypt(room: Room): void;
/**
* Encrypt an event according to the configuration of the room.
*
@@ -112,14 +80,24 @@ export interface CryptoBackend extends SyncCryptoCallbacks {
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Get a list containing all of the room keys
* Finds a DM verification request that is already in progress for the given room id
*
* This should be encrypted before returning it to the user.
* @param roomId - the room to use for verification
*
* @returns a promise which resolves to a list of
* session export objects
* @returns the VerificationRequest that is in progress, if any
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
}
/** The methods which crypto implementations should expose to the Sync api */
@@ -138,6 +116,22 @@ export interface SyncCryptoCallbacks {
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* Called by the /sync loop when one time key counts and unused fallback key details are received.
*
* @param oneTimeKeysCounts - the received one time key counts
* @param unusedFallbackKeys - the received unused fallback keys
*/
processKeyCounts(oneTimeKeysCounts?: Record<string, number>, unusedFallbackKeys?: string[]): Promise<void>;
/**
* Handle the notification from /sync that device lists have
* been changed.
*
* @param deviceLists - device_lists field from /sync
*/
processDeviceLists(deviceLists: IDeviceLists): Promise<void>;
/**
* Called by the /sync loop whenever an m.room.encryption event is received.
*
+2 -2
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as utils from "./utils";
import { encodeParams } from "./utils";
/**
* Get the HTTP URL for an MXC URI.
@@ -74,6 +74,6 @@ export function getHttpUriForMxc(
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
}
const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params);
const urlParams = Object.keys(params).length === 0 ? "" : "?" + encodeParams(params);
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
}

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