Compare commits

..

139 Commits

Author SHA1 Message Date
ElementRobot 3f095caf2d v26.2.0 2023-07-04 15:07:04 +01:00
ElementRobot 12a94bdd94 Prepare changelog for v26.2.0 2023-07-04 15:07:02 +01:00
ElementRobot e285932776 v26.2.0-rc.1 2023-06-27 11:57:18 +01:00
ElementRobot 2af0706b16 Prepare changelog for v26.2.0-rc.1 2023-06-27 11:57:15 +01:00
Richard van der Hoff 4382d2a425 Increase another crypto test timeout (#3509)
Followup to https://github.com/matrix-org/matrix-js-sdk/pull/3500: increase the
timeout for another test which is also timing out.
2023-06-27 10:06:48 +00:00
Kerry 9de4a057df OIDC: navigate to authorization endpoint (#3499)
* utils for authorization step in OIDC code grant

* tidy

* completeAuthorizationCodeGrant util functions

* response_mode=query

* add scope to bearertoken type

* add is_guest to whoami response type

* doc comments

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

* use shimmed TextEncoder

* fetchMockJest -> fetchMock

* comment

* bearertokenresponse

* test for lowercase bearer

* handle lowercase token_type

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-06-26 23:46:53 +00:00
Richard van der Hoff b703d4a2cc More slow test fixes (#3515)
We still seem to be suffering test timeouts. Hopefully this will fix the integ tests, where #3509 etc fix the unit tests.
2023-06-26 22:15:56 +01:00
Richard van der Hoff d1dec4cd08 Implement VerificationRequest.cancel (#3505) 2023-06-26 16:56:57 +00:00
Richard van der Hoff 326a13bcfe Rearrange the verification integration tests, again (#3504)
* Element-R: Implement `CryptoApi.getVerificationRequestsToDeviceInProgress`

* Element-R: Implement `requestOwnUserVerification`

* init aliceClient *after* the fetch interceptors

* Initialise the test client separately for each test

* Avoid running all the tests twice

Currently all of these tests are running twice, with different client
configurations. That's not really adding much value; we just need to run
specific tests that way.

* Factor out functions for building responses
2023-06-26 14:44:42 +00:00
renovate[bot] e8fb47fdca Update all non-major dependencies (#3467)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-26 14:30:06 +00:00
Richard van der Hoff bd66e3859d Element R: Implement requestOwnUserVerification (#3508)
Part of https://github.com/vector-im/element-web/issues/25319.
2023-06-26 15:17:35 +01:00
Richard van der Hoff 96e484a3fe Element-R: implement CryptoApi.getVerificationRequestsToDeviceInProgress (#3497)
* Element-R: Implement `CryptoApi.getVerificationRequestsToDeviceInProgress`

* Element-R: Implement `requestOwnUserVerification` (#3503)

* Revert "Element-R: Implement `requestOwnUserVerification` (#3503)"

This reverts commit 8da756503c3d72b8ecbf50b4c2cf807ac36229aa.

oops, merged too soon
2023-06-26 13:31:35 +00:00
Richard van der Hoff 3e646bdfa0 Bump version of the react-sdk cypress workflow file (#3501)
`cypress.yaml` is currently pinned to an old version of the react-sdk, meaning
that each attempt to run it is currently failing with an error.

(Introduced by https://github.com/matrix-org/matrix-js-sdk/pull/3480)
2023-06-26 09:09:20 +00:00
Richard van der Hoff 48c4127035 Element-R: Basic implementation of SAS verification (#3490)
* Return uploaded keys from `/keys/query`

* Basic implementation of SAS verification in Rust

* Update the `verifier` *before* emitting `erificationRequestEvent.Change`

* remove dead code
2023-06-26 08:48:44 +00:00
Michael Telatynski f16a6bc654 Aggregate relations regardless of whether event fits into the timeline (#3496) 2023-06-26 09:39:25 +01:00
Richard van der Hoff f884c78579 Improve integration test for interactive verification (#3495)
* Tweaks to the integ test to conform to the spec

Rust is a bit more insistent than legacy crypto...

* Improve documentation on request*Verification

* Check more things in the integration test

* Create an E2EKeyResponder

* Test verification with custom method list

* Add a test for SAS cancellation

* Update spec/integ/crypto/verification.spec.ts
2023-06-23 14:38:38 +00:00
Florian Duros 3c59476cf7 Element-R: Store cross signing keys in secret storage (#3498)
* Store cross signing keys in secret storage

* Update `bootstrapSecretStorage` doc

* Throw error when `createSecretStorageKey` is not set

* Move mocking functions

* Store cross signing keys and user signing keys

* Fix `awaitCrossSigningKeyUpload` documentation

* Remove useless comment

* Fix formatting after merge conflict
2023-06-23 13:10:54 +00:00
Richard van der Hoff c8f6c4dd0d Increase crypto test timeout (#3500)
For some reason, some tests seem to be timing out in GHA. Let's try bumping up
the timeout.
2023-06-23 12:32:56 +00:00
Richard van der Hoff e8c89e9977 Element-R: speed up slow unit test (#3492)
A couple of tests were waiting for a request that wasn't happening, so timing
out after 1.5 seconds. Let's avoid the extra slowth.

(This was introduced by changes in
https://github.com/matrix-org/matrix-js-sdk/pull/3487, but the changes in this
PR do no harm anyway)
2023-06-22 09:43:39 +00:00
Kerry df78d7cf67 OIDC: add dynamic client registration util function (#3481)
* rename OidcDiscoveryError to OidcError

* oidc client registration functions

* test registerOidcClient

* tidy test file

* reexport OidcDiscoveryError for backwards compatibility
2023-06-21 21:55:25 +00:00
Michael Telatynski 80fec814a2 Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room (#3493) 2023-06-21 12:54:30 +01:00
Michael Telatynski 8b9672ba43 Add debug logging to figure out missing reactions in main timeline (#3494)
* Fix debug logging not working

* Add debug logging to figure out missing reactions in main timeline
2023-06-20 15:28:02 +00:00
David Baker ca00094e67 Fix bug where switching media caused media in subsequent calls to fail (#3489) 2023-06-20 15:16:43 +00:00
Richard van der Hoff 9c6d5a6c55 Element-R: wait for OlmMachine on startup (#3487)
* Element-R: wait for OlmMachine on startup

Previously, if you called `CryptoApi.getUserDeviceInfo()` before the first
`/sync` request happened, it would return an empty list, which made a bunch of
the tests racy. Add a hack to get the OlmMachine to think about its device
lists during startup.

* add a test
2023-06-20 09:29:41 +00:00
RiotRobot b77fe465f7 Resetting package fields for development 2023-06-20 10:14:37 +01:00
RiotRobot 8d93f49443 Merge branch 'master' into develop 2023-06-20 10:14:32 +01:00
RiotRobot f03fbed668 v26.1.0 2023-06-20 10:12:27 +01:00
RiotRobot c84efb57ef Prepare changelog for v26.1.0 2023-06-20 10:12:24 +01:00
Florian Duros 49f11578f7 ElementR: Add CryptoApi#bootstrapSecretStorage (#3483)
* Add WIP bootstrapSecretStorage

* Add new test if `createSecretStorageKey` is not set

* Remove old comments

* Add docs for `crypto-api.bootstrapSecretStorage`

* Remove default parameter for `createSecretStorageKey`

* Move `bootstrapSecretStorage` next to `isSecretStorageReady`

* Deprecate `bootstrapSecretStorage` in `MatrixClient`

* Update documentations

* Raise error if missing `keyInfo`

* Update behavior around `setupNewSecretStorage`

* Move `ICreateSecretStorageOpts` to `rust-crypto`

* Move `ICryptoCallbacks` to `rust-crypto`

* Update `bootstrapSecretStorage` documentation

* Add partial `CryptoCallbacks` documentation

* Fix typo

* Review changes

* Review changes
2023-06-20 08:40:11 +00:00
Richard van der Hoff 8df4be0939 Fix order of things in crypto-api.ts (#3491)
* `CryptoApi` should be first
* `export *` should be last
* everything else in the middle
2023-06-20 08:12:33 +00:00
Richard van der Hoff 80cdbe1058 Element-R: implement userHasCrossSigningKeys (#3488) 2023-06-19 21:11:04 +00:00
Michael Telatynski 9c62d15447 Specify git tags for cypress workflow so updating tests is gated by renovate PRs (#3480) 2023-06-19 07:38:24 +00:00
renovate[bot] afc70528cc Update definitelyTyped (#3465)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 02:35:02 +00:00
Kerry f938d10f7b remove polls from room state on redaction (#3475) 2023-06-15 22:12:03 +00:00
Richard van der Hoff 22f0b781ea Add new methods for verification to CryptoApi (#3474)
* Add accessors for verification requests to CryptoApi

Part of https://github.com/vector-im/crypto-internal/issues/97

* Add new methods for verification to `CryptoApi` and deprecate old method

https://github.com/vector-im/crypto-internal/issues/98
2023-06-15 14:56:50 +01:00
Florian Duros 1bae10c4b2 Fix export type GeneratedSecretStorageKey (#3479) 2023-06-15 12:46:42 +00:00
Michael Telatynski 9b5b533c6f Close IDB database before deleting it to prevent spurious unexpected close errors (#3478) 2023-06-15 12:25:29 +00:00
Richard van der Hoff c425945353 Avoid deprecated classes in verification integ test (#3473)
https://github.com/matrix-org/matrix-js-sdk/pull/3449 deprecated a bunch of
exports from `src/crypto/verification/request/VerificationRequest`. Let's stop
using them in the integration test.
2023-06-15 11:24:01 +00:00
Florian Duros 0545f6df09 ElementR: Add rust-crypto#createRecoveryKeyFromPassphrase implementation (#3472)
* Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation

* Use `crypto`

* Rename `IRecoveryKey` into `GeneratedSecretStorageKey` for rust crypto

* Improve comments

* Improve `createRecoveryKeyFromPassphrase`
2023-06-14 14:38:43 +00:00
renovate[bot] d14fc426e6 Update dependency eslint-plugin-jsdoc to v46 (#3468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-14 10:49:28 +00:00
Michael Cousins 9f30defcd1 Upgrade JS-DevTools/npm-publish to v2 (#3456)
- Upgrade JS-DevTools/npm-publish to v2.2.0
- Remove workaround for bug JS-DevTools/npm-publish#15
- Remove usage of `jq` in favor of npm-publish output

Signed-off-by: Michael Cousins <michael@cousins.io>
2023-06-14 08:02:48 +00:00
renovate[bot] 9b175a4985 Update typescript-eslint monorepo to v5.59.9 (#3466)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-13 20:52:40 +00:00
Richard van der Hoff 826ea5bc58 Pull out a new VerificationRequest interface (#3449)
* add a test for incoming verification requests

* Move `VerificationRequestEvent` to crypto-api

* Move `VerificationPhase` to `crypto-api`

* Define `VerificationRequest` interface

* Implement `canAcceptVerificationRequest`
2023-06-13 11:55:37 +00:00
RiotRobot 7b316613f6 v26.1.0-rc1 2023-06-13 12:15:32 +01:00
RiotRobot 387b3485ae Prepare changelog for v26.1.0-rc1 2023-06-13 12:15:29 +01:00
Valere 9f6073478f Element-R: support for manual import/export of Room keys (#3364)
* Rust manual import/export for keys

* code review

* code review

* post merge fix

* code review

* doc: comma splice

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

* Better test name

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

* quick doc update

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

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-06-13 10:28:35 +00:00
Florian Duros b47c87f909 Add rust-crypto#isCrossSigningReady implementation (#3462) 2023-06-12 14:00:31 +00:00
Kerry c66850e897 OIDC: Validate m.authentication configuration (#3419)
* validate m.authentication, fetch issuer wellknown

* move validation functions into separate file

* test validateWellKnownAuthentication

* test validateOIDCIssuerWellKnown

* add authentication cases to autodiscovery tests

* test invalid authentication config on wk

* improve types

* test case for account:false

* use hasOwnProperty in validateWellKnownAuthentication

* comments

* make registration_endpoint optional
2023-06-11 21:32:44 +00:00
Florian Duros 2766146c49 ElementR: Add CryptoApi.getCrossSigningStatus (#3452)
* Add `crypto.getCrossSigningStatus`

* Fix imports and boolean casting

* Moved `isStoredInSecretStorage` into a single function

* Review changes `CrossSigningStatus`

* Review changes for `cross-signing.spec.ts`

* Add test in case when cross signing is not setup

* Handle when the `crossSigningStatus` returned by the olmMachine is null

* Review changes for `crypto-api` documentation

* Update `cross-signing.spec.ts` according to review changes

* Moved and renamed `isStoredInSecretStorage`

* Remove noise in `CrossSigning.ts` imports

* Fix `returns` sentence in `secretStorageContainsCrossSigningKeys`

* Fix typos

* Add test for `secret-storage.ts`

* Improve documentation

* Add doc about fetch mock request name
2023-06-09 13:15:01 +00:00
Richard van der Hoff 61497c9a8f Pass Kiwi secrets to react-sdk Cypress build (#3461) 2023-06-09 11:03:33 +00:00
Richard van der Hoff 5981feeb44 Element-R: Pull out an interface from VerificationBase (#3414)
* pull out `Verifier` interface

* Mark old classes as deprecated

* Update integration tests to use new interface
2023-06-09 10:09:31 +00:00
Michael Telatynski 51218ddc1d Fix thread list being ordered based on all updates (#3458)
* Add test for thread list stability around non-reply updates

* Fix thread list being ordered based on all updates

* Fix test

* Update spec/integ/matrix-client-event-timeline.spec.ts

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

* Iterate

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-06-09 08:29:21 +00:00
RiotRobot b907433d38 Merge branch 'master' into develop 2023-06-09 09:27:19 +01:00
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
Kerry ef1f5bf232 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
2023-06-08 21:36:34 +00:00
Florian Duros a03e3dd501 Update @matrix-org/matrix-sdk-crypto-js to 0.1.0-alpha.10 (#3453) 2023-06-07 14:10:38 +00:00
Enrico Schwendig 3cfad3cdeb Expand webrtc stats with connection and call feed track information (#3421)
* 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

* add call feed webrtc report

* add logger for error case in stats gathering

* gather connection track report

* expand call feed stats with call feed

* formation code and fix lint issues

* clean up new track stats

 * set label for call feed stats and
 * remove stream in track stats
 * transceiver stats based on mid
 * call feed stats based on stream id
 * fix lint and test issues

* Fix merge issues

* Add test for expanding call feed stats in group call

* Fix export issue from prv PR

* explain test data and fixed some linter issues

* convert tests to snapshot tests
2023-06-07 14:05:51 +00:00
Timo 60c715d5df Extend stats summary with call device and user count based on room state (#3424)
* send expected peer connections to posthog.
(based on roomState event)

* add tests

* change GroupCallStats initialized

* prettier

* more test and catch for promise

* seperate the participant logic in a summary extend function

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

* remove unused

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

* rename summaryStatsReportGatherer to "Reporter"
for the summary stats there is only one instance because there is only
one summary. Since we dont have a list of gatherers it this class only reports.
Hence we rename it to be a reporter.

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

* review

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

* Update src/webrtc/stats/groupCallStats.ts

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

* revert rename

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

* Update all non-major dependencies (#3433)

* Update all non-major dependencies

* Remove name wrap-ansi-cjs

* Remove name string-width-cjs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Update definitelyTyped (#3430)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* Export FALLBACK_ICE_SERVER (#3429)

* Add an integration test for verification (#3436)

* Move existing crypto integ tests into a subdirectory

* Factor out some common bits from `crypto.spec.ts`

* Integration test for device verification

* Ignore generated file in prettier

* Always show a summary after Jest tests (#3440)

... because it is otherwise impossible to see what failed.

* Use correct /v3 prefix for /refresh (#3016)

* Add tests to ensure /v3/refresh is called + automatic /v1 retry

* Request /refresh with v3 prefix, and quietly fall back to v1

* Add tests checking re-raising errors

* Update spec/unit/login.spec.ts

* Update comment

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

---------

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

* Update Mutual Rooms (MSC2666) support (#3381)

* update mutual rooms support

* clarify docs and switch eslint comment with todo

* please the holy linter

* change query variable names around

* add mock tests and fix issue

* ye holy linter

* GHA: build and cypress-test a copy of element-web after each push (#3412)

* Build a copy of element-web after each push

* Run cypress after each build of element-web

* Fix downstream-artifacts build (#3443)

* Fix downstream-artifacts build

* Update cypress.yml

* Fix edge cases around 2nd order relations and threads (#3437)

* Fix tests oversimplifying threads fixtures

* Check for unsigned thread_id in MatrixEvent::threadRootId

* Fix threads order being racy

* Make Sonar happier

* Iterate

* Make sliding sync linearize processing of sync requests (#3442)

* Make sliding sync linearize processing of sync requests

* Iterate

* Iterate

* Iterate

* Iterate

* Disable downstream artifacts build for develop branch (#3444)

* Export thread-related types from SDK (#3447)

* Export thread-related types from SDK

* address PR feedback

* Integration test for QR code verification (#3439)

* Integration test for QR code verification

Followup to https://github.com/matrix-org/matrix-js-sdk/pull/3436: another
integration test, this time using the QR code flow

* Use Object.defineProperty, and restore afterwards

Apparently global.crypto exists in some environments

* apply ts-ignore

* remove stray comment

* Update spec/integ/crypto/verification.spec.ts

* Add `getShowSasCallbacks`, `getShowQrCodeCallbacks` to VerifierBase (#3422)

* Add `getShowSasCallbacks`, `getShowQrCodeCallbacks` to VerifierBase

... to avoid some type-casting

* Integration test for QR code verification

Followup to https://github.com/matrix-org/matrix-js-sdk/pull/3436: another
integration test, this time using the QR code flow

* Rename method

... it turns out not to be used quite as I thought.

* tests for new methods

* Use Object.defineProperty, and restore afterwards

Apparently global.crypto exists in some environments

* apply ts-ignore

* More test coverage

* fix bad merge

* Fix changelog_head.py script to be Python 3 compatible

* Prepare changelog for v25.2.0-rc.1

* v25.2.0-rc.1

* Fix tsconfig-build.json

* Prepare changelog for v25.2.0-rc.2

* v25.2.0-rc.2

* Fix docs deployment

* Prepare changelog for v25.2.0-rc.3

* v25.2.0-rc.3

* Prepare changelog for v25.2.0-rc.4

* v25.2.0-rc.4

* [Backport staging] Attempt a potential workaround for stuck notifs (#3387)

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

* Prepare changelog for v25.2.0-rc.5

* v25.2.0-rc.5

* [Backport staging] Fix mark as unread button (#3401)

Co-authored-by: Michael Weimann <michaelw@matrix.org>

* Prepare changelog for v26.0.0-rc.1

* v26.0.0-rc.1

* Prepare changelog for v26.0.0

* v26.0.0

* Resetting package fields for development

* use cli.canSupport to determine intentional mentions support (#3445)

* use cli.canSupport to determine intentional mentions support

* more specific comment

* Update src/client.ts

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

---------

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

* git fixup

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

* import updates

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

* dont revert enricos change

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

* temp rename for lowercase

* lowercase

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: David Lee <david@david-lee.net>
Co-authored-by: Jonathan de Jong <jonathan@automatia.nl>
Co-authored-by: Stanislav Demydiuk <stas-demydiuk@users.noreply.github.com>
Co-authored-by: ElementRobot <releases@riot.im>
Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
Co-authored-by: Michael Weimann <michaelw@matrix.org>
Co-authored-by: Kerry <kerrya@element.io>
2023-06-07 13:36:40 +00:00
Richard van der Hoff c2942ddbc7 Add new methods to VerificationRequest (#3441)
* Add new method `VerificationRequest.getQRCodeBytes`

... which requires fewer complicated classes than the existing `qrCodeData`

* Add new property `VerificationRequest.otherDeviceId`

... to save going via `.channel`

* Add more methods to `VerificationRequest`

... to avoid the need for `channel`

* Use new methods in integration tests
2023-06-07 13:23:27 +00:00
Michael Weimann 2d7fdde7ed Update MSC3912 implementation to use with_rel_type instead of with_relations (#3420)
* Migrate MSC3912

* Fix doc blocks

* Remove with_relations fallback

* Implement PR feedback

* Fix typo
2023-06-07 12:05:14 +00:00
Enrico Schwendig cf34e90cb4 Fix export issue from thread module (#3451)
* Fix export issue from thread module

* separate type from class export

* follow the pattern of what other exports do
2023-06-07 09:35:20 +00:00
Kerry f0fa4d2cc8 use cli.canSupport to determine intentional mentions support (#3445)
* use cli.canSupport to determine intentional mentions support

* more specific comment

* Update src/client.ts

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

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-06-06 21:52:55 +00:00
RiotRobot c52e4b6329 Resetting package fields for development 2023-06-06 13:45:40 +01:00
RiotRobot 9451b55985 Merge branch 'master' into develop 2023-06-06 13:45:34 +01: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
Richard van der Hoff 39714bfe6f Add getShowSasCallbacks, getShowQrCodeCallbacks to VerifierBase (#3422)
* Add `getShowSasCallbacks`, `getShowQrCodeCallbacks` to VerifierBase

... to avoid some type-casting

* Integration test for QR code verification

Followup to https://github.com/matrix-org/matrix-js-sdk/pull/3436: another
integration test, this time using the QR code flow

* Rename method

... it turns out not to be used quite as I thought.

* tests for new methods

* Use Object.defineProperty, and restore afterwards

Apparently global.crypto exists in some environments

* apply ts-ignore

* More test coverage

* fix bad merge
2023-06-06 11:16:19 +00:00
Richard van der Hoff f5f6100b1e Integration test for QR code verification (#3439)
* Integration test for QR code verification

Followup to https://github.com/matrix-org/matrix-js-sdk/pull/3436: another
integration test, this time using the QR code flow

* Use Object.defineProperty, and restore afterwards

Apparently global.crypto exists in some environments

* apply ts-ignore

* remove stray comment

* Update spec/integ/crypto/verification.spec.ts
2023-06-06 10:22:12 +00:00
Stanislav Demydiuk ecd700a36e Export thread-related types from SDK (#3447)
* Export thread-related types from SDK

* address PR feedback
2023-06-06 07:59:10 +00:00
Richard van der Hoff ea7042efb9 Disable downstream artifacts build for develop branch (#3444) 2023-06-05 17:38:08 +00:00
Michael Telatynski 04a6c4e6c4 Make sliding sync linearize processing of sync requests (#3442)
* Make sliding sync linearize processing of sync requests

* Iterate

* Iterate

* Iterate

* Iterate
2023-06-05 16:40:19 +00:00
Michael Telatynski 0329824cab Fix edge cases around 2nd order relations and threads (#3437)
* Fix tests oversimplifying threads fixtures

* Check for unsigned thread_id in MatrixEvent::threadRootId

* Fix threads order being racy

* Make Sonar happier

* Iterate
2023-06-05 15:18:56 +00:00
Richard van der Hoff 3351c4f57a Fix downstream-artifacts build (#3443)
* Fix downstream-artifacts build

* Update cypress.yml
2023-06-05 15:05:14 +00:00
Richard van der Hoff e70a1a1eff GHA: build and cypress-test a copy of element-web after each push (#3412)
* Build a copy of element-web after each push

* Run cypress after each build of element-web
2023-06-05 09:11:00 +00:00
Jonathan de Jong 1a5af9d8e3 Update Mutual Rooms (MSC2666) support (#3381)
* update mutual rooms support

* clarify docs and switch eslint comment with todo

* please the holy linter

* change query variable names around

* add mock tests and fix issue

* ye holy linter
2023-06-05 08:23:44 +00:00
David Lee 258f157ebc Use correct /v3 prefix for /refresh (#3016)
* Add tests to ensure /v3/refresh is called + automatic /v1 retry

* Request /refresh with v3 prefix, and quietly fall back to v1

* Add tests checking re-raising errors

* Update spec/unit/login.spec.ts

* Update comment

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

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-06-03 14:56:12 +00:00
Richard van der Hoff dfb079a76f Always show a summary after Jest tests (#3440)
... because it is otherwise impossible to see what failed.
2023-06-02 17:38:17 +00:00
Richard van der Hoff 858155e0ef Add an integration test for verification (#3436)
* Move existing crypto integ tests into a subdirectory

* Factor out some common bits from `crypto.spec.ts`

* Integration test for device verification

* Ignore generated file in prettier
2023-06-02 15:01:21 +00:00
Michael Telatynski 946a1cef0f Export FALLBACK_ICE_SERVER (#3429) 2023-06-02 14:29:49 +00:00
renovate[bot] 6c1fdbb7e9 Update definitelyTyped (#3430)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-06-02 13:36:53 +00:00
renovate[bot] 8cd7c96496 Update all non-major dependencies (#3433)
* Update all non-major dependencies

* Remove name wrap-ansi-cjs

* Remove name string-width-cjs

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-06-02 13:33:05 +00:00
renovate[bot] cc9d530bcf Update dependency eslint-plugin-jsdoc to v45 (#3435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-01 16:38:45 +00:00
renovate[bot] cabf6da6a7 Update typescript-eslint monorepo to v5.59.7 (#3432)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-01 16:38:30 +00:00
renovate[bot] 895a82efcd Update dependency @types/jest to v29.5.2 (#3431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-01 16:38:26 +00: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
140 changed files with 9908 additions and 1698 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ module.exports = {
"jest/no-standalone-expect": [
"error",
{
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"],
additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"],
},
],
},
+31
View File
@@ -0,0 +1,31 @@
# Triggers after the "Downstream artifacts" build has finished, to run the
# cypress tests (with access to repo secrets)
name: matrix-react-sdk Cypress End to End Tests
on:
workflow_run:
workflows: ["Build downstream artifacts"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
cypress:
name: Cypress
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.74.0
permissions:
actions: read
issues: read
statuses: write
pull-requests: read
secrets:
# secrets are not automatically shared with called workflows, so share the cypress dashboard key, and the Kiwi login details
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
with:
react-sdk-repository: matrix-org/matrix-react-sdk
rust-crypto: true
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
path: docs
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v1
uses: matrix-org/netlify-pr-preview@v2
with:
path: docs
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
@@ -0,0 +1,25 @@
name: Build downstream artifacts
on:
pull_request: {}
merge_group:
types: [checks_requested]
# For now at least, we don't run this or the cypress-tests against pushes
# to develop or master.
#
# Note that if we later choose to do so, we'll need to find a way to stop
# the results in Cypress Cloud from clobbering those from the 'develop'
# branch of matrix-react-sdk.
#
#push:
# branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-element-web:
name: Build element-web
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.73.1
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
+6 -8
View File
@@ -24,18 +24,16 @@ jobs:
- name: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
uses: JS-DevTools/npm-publish@a25b4180b728b0279fca97d4e5bccf391685aead # v2.2.0
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
tag: next
ignore-scripts: false
- name: 🎖️ Add `latest` dist-tag to final releases
if: github.event.release.prerelease == false
run: |
package=$(cat package.json | jq -er .name)
npm dist-tag add "$package@$release" latest
if: github.event.release.prerelease == false && steps.npm-publish.outputs.id
run: npm dist-tag add "$release" latest
env:
# JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc
INPUT_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.version }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.id }}
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: 📖 Generate docs
run: |
yarn tpv --yes --out _docs --stale --major 10
yarn tpv purge --yes --out _docs --stale --major 10
yarn gendoc
symlinks -rc _docs
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
if: github.event.workflow_run.event != 'merge_group'
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 }}
@@ -36,7 +36,7 @@ jobs:
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.event != 'merge_group'
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:
+3
View File
@@ -24,3 +24,6 @@ out
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md
# This file is also autogenerated
/spec/test-utils/test-data/index.ts
+70 -3
View File
@@ -1,10 +1,72 @@
Changes in [25.2.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.2.0-rc.2) (2023-05-16)
============================================================================================================
Changes in [26.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0) (2023-07-04)
==================================================================================================
## 🦖 Deprecations
* Deprecate device methods in MatrixClient ([\#3357](https://github.com/matrix-org/matrix-js-sdk/pull/3357)).
* The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. ([\#3189](https://github.com/matrix-org/matrix-js-sdk/issues/3189)).
* ElementR: Add `CryptoApi#bootstrapSecretStorage` ([\#3483](https://github.com/matrix-org/matrix-js-sdk/pull/3483)). Contributed by @florianduros.
* Deprecate `MatrixClient.findVerificationRequestDMInProgress`, `MatrixClient.getVerificationRequestsToDeviceInProgress`, and `MatrixClient.requestVerification`, in favour of methods in `CryptoApi`. ([\#3474](https://github.com/matrix-org/matrix-js-sdk/pull/3474)).
* Introduce a new `Crypto.VerificationRequest` interface, and deprecate direct access to the old `VerificationRequest` class. Also deprecate some related classes that were exported from `src/crypto/verification/request/VerificationRequest` ([\#3449](https://github.com/matrix-org/matrix-js-sdk/pull/3449)).
## ✨ Features
* OIDC: navigate to authorization endpoint ([\#3499](https://github.com/matrix-org/matrix-js-sdk/pull/3499)). Contributed by @kerryarchibald.
* Support for interactive device verification in Element-R. ([\#3505](https://github.com/matrix-org/matrix-js-sdk/pull/3505)).
* Support for interactive device verification in Element-R. ([\#3508](https://github.com/matrix-org/matrix-js-sdk/pull/3508)).
* Support for interactive device verification in Element-R. ([\#3490](https://github.com/matrix-org/matrix-js-sdk/pull/3490)). Fixes vector-im/element-web#25316.
* Element-R: Store cross signing keys in secret storage ([\#3498](https://github.com/matrix-org/matrix-js-sdk/pull/3498)). Contributed by @florianduros.
* OIDC: add dynamic client registration util function ([\#3481](https://github.com/matrix-org/matrix-js-sdk/pull/3481)). Contributed by @kerryarchibald.
* Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room ([\#3493](https://github.com/matrix-org/matrix-js-sdk/pull/3493)).
* ElementR: Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation ([\#3472](https://github.com/matrix-org/matrix-js-sdk/pull/3472)). Contributed by @florianduros.
## 🐛 Bug Fixes
* Aggregate relations regardless of whether event fits into the timeline ([\#3496](https://github.com/matrix-org/matrix-js-sdk/pull/3496)). Fixes vector-im/element-web#25596.
* Fix bug where switching media caused media in subsequent calls to fail ([\#3489](https://github.com/matrix-org/matrix-js-sdk/pull/3489)).
* Fix: remove polls from room state on redaction ([\#3475](https://github.com/matrix-org/matrix-js-sdk/pull/3475)). Fixes vector-im/element-web#25573. Contributed by @kerryarchibald.
* Fix export type `GeneratedSecretStorageKey` ([\#3479](https://github.com/matrix-org/matrix-js-sdk/pull/3479)). Contributed by @florianduros.
* Close IDB database before deleting it to prevent spurious unexpected close errors ([\#3478](https://github.com/matrix-org/matrix-js-sdk/pull/3478)). Fixes vector-im/element-web#25597.
Changes in [26.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.1.0) (2023-06-20)
==================================================================================================
## 🦖 Deprecations
* Introduce a new `Crypto.Verifier` interface, and deprecate direct access to `VerificationBase`, `SAS` and `ReciprocateQRCode` ([\#3414](https://github.com/matrix-org/matrix-js-sdk/pull/3414)).
## ✨ Features
* Add `rust-crypto#isCrossSigningReady` implementation ([\#3462](https://github.com/matrix-org/matrix-js-sdk/pull/3462)). Contributed by @florianduros.
* OIDC: Validate `m.authentication` configuration ([\#3419](https://github.com/matrix-org/matrix-js-sdk/pull/3419)). Contributed by @kerryarchibald.
* ElementR: Add `CryptoApi.getCrossSigningStatus` ([\#3452](https://github.com/matrix-org/matrix-js-sdk/pull/3452)). Contributed by @florianduros.
* Extend stats summary with call device and user count based on room state ([\#3424](https://github.com/matrix-org/matrix-js-sdk/pull/3424)). Contributed by @toger5.
* Update MSC3912 implementation to use `with_rel_type` instead of `with_relations` ([\#3420](https://github.com/matrix-org/matrix-js-sdk/pull/3420)).
* Export thread-related types from SDK ([\#3447](https://github.com/matrix-org/matrix-js-sdk/pull/3447)). Contributed by @stas-demydiuk.
* Use correct /v3 prefix for /refresh ([\#3016](https://github.com/matrix-org/matrix-js-sdk/pull/3016)). Contributed by @davidisaaclee.
## 🐛 Bug Fixes
* Fix thread list being ordered based on all updates ([\#3458](https://github.com/matrix-org/matrix-js-sdk/pull/3458)). Fixes vector-im/element-web#25522.
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
* use cli.canSupport to determine intentional mentions support ([\#3445](https://github.com/matrix-org/matrix-js-sdk/pull/3445)). Fixes vector-im/element-web#25497. Contributed by @kerryarchibald.
* Make sliding sync linearize processing of sync requests ([\#3442](https://github.com/matrix-org/matrix-js-sdk/pull/3442)).
* Fix edge cases around 2nd order relations and threads ([\#3437](https://github.com/matrix-org/matrix-js-sdk/pull/3437)).
Changes in [26.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.1) (2023-06-09)
==================================================================================================
## 🐛 Bug Fixes
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
Changes in [26.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.0) (2023-06-06)
==================================================================================================
## 🚨 BREAKING CHANGES
* Ensure we do not add relations to the wrong timeline ([\#3427](https://github.com/matrix-org/matrix-js-sdk/pull/3427)). Fixes vector-im/element-web#25450 and vector-im/element-web#25494.
* Deprecate `QrCodeEvent`, `SasEvent` and `VerificationEvent` ([\#3386](https://github.com/matrix-org/matrix-js-sdk/pull/3386)).
## 🦖 Deprecations
* Move crypto classes into a separate namespace ([\#3385](https://github.com/matrix-org/matrix-js-sdk/pull/3385)).
## ✨ Features
* Mention deno support in the README ([\#3417](https://github.com/matrix-org/matrix-js-sdk/pull/3417)). Contributed by @sigmaSd.
* Mark room version 10 as safe ([\#3425](https://github.com/matrix-org/matrix-js-sdk/pull/3425)).
* Prioritise entirely supported flows for UIA ([\#3402](https://github.com/matrix-org/matrix-js-sdk/pull/3402)).
* Add methods to terminate idb worker ([\#3362](https://github.com/matrix-org/matrix-js-sdk/pull/3362)).
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
@@ -13,6 +75,11 @@ Changes in [25.2.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/ta
* 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)
==================================================================================================
+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
+9 -1
View File
@@ -23,11 +23,19 @@ const config: Config = {
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
coverageReporters: ["text-summary", "lcov"],
testResultsProcessor: "@casualbot/jest-sonar-reporter",
// Always print out a summary if there are any failing tests. Normally
// a summary is only printed if there are more than 20 test *suites*.
reporters: [["default", { summaryThreshold: 0 }]],
};
// if we're running under GHA, enable the GHA reporter
if (env["GITHUB_ACTIONS"] !== undefined) {
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
const reporters: Config["reporters"] = [
["github-actions", { silent: false }],
// as above: always show a summary if there were any failing tests.
["summary", { summaryThreshold: 0 }],
];
// if we're running against the develop branch, also enable the slow test reporter
if (env["GITHUB_REF"] == "refs/heads/develop") {
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "25.2.0-rc.2",
"version": "26.2.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -55,7 +55,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.11",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -101,16 +101,16 @@
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.39.0",
"eslint": "8.41.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": "^43.0.6",
"eslint-plugin-jsdoc": "^46.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^46.0.0",
"eslint-plugin-unicorn": "^47.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fetch-mock-jest": "^1.5.1",
@@ -125,6 +125,7 @@
"ts-node": "^10.9.1",
"tsify": "^5.0.2",
"typedoc": "^0.24.0",
"typedoc-plugin-coverage": "^2.1.0",
"typedoc-plugin-mdn-links": "^3.0.3",
"typedoc-plugin-missing-exports": "^2.0.0",
"typedoc-plugin-versions": "^0.2.3",
@@ -18,8 +18,9 @@ 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";
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
import { createClient, IAuthDict, MatrixClient } from "../../../src";
import { mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -38,10 +39,6 @@ const TEST_DEVICE_ID = "xzcvb";
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
let aliceClient: MatrixClient;
beforeEach(async () => {
@@ -65,30 +62,25 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
fetchMock.mockReset();
});
/**
* Create cross-signing keys and publish the keys
*
* @param authDict - The parameters to as the `auth` dict in the key upload request.
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
*/
async function bootstrapCrossSigning(authDict: IAuthDict): Promise<void> {
await aliceClient.getCrypto()?.bootstrapCrossSigning({
authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined),
});
}
describe("bootstrapCrossSigning (before initialsync completes)", () => {
oldBackendOnly("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(
{ url: "path:/_matrix/client/unstable/keys/device_signing/upload", name: "upload-keys" },
{},
);
it("publishes keys if none were yet published", async () => {
mockSetupCrossSigningRequests();
// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
await makeRequest(authDict);
};
// now bootstrap cross signing, and check it resolves successfully
await aliceClient.bootstrapCrossSigning({
authUploadDeviceSigningKeys: uiaCallback,
});
await bootstrapCrossSigning(authDict);
// check the cross-signing keys upload
expect(fetchMock.called("upload-keys")).toBeTruthy();
@@ -114,4 +106,55 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
);
});
});
describe("getCrossSigningStatus()", () => {
it("should return correct values without bootstrapping cross-signing", async () => {
mockSetupCrossSigningRequests();
const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();
// Expect the cross signing keys to be unavailable
expect(crossSigningStatus).toStrictEqual({
publicKeysOnDevice: false,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: { masterKey: false, userSigningKey: false, selfSigningKey: false },
});
});
it("should return correct values after bootstrapping cross-signing", async () => {
mockSetupCrossSigningRequests();
// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();
// Expect the cross signing keys to be available
expect(crossSigningStatus).toStrictEqual({
publicKeysOnDevice: true,
privateKeysInSecretStorage: false,
privateKeysCachedLocally: { masterKey: true, userSigningKey: true, selfSigningKey: true },
});
});
});
describe("isCrossSigningReady()", () => {
it("should return false if cross-signing is not bootstrapped", async () => {
mockSetupCrossSigningRequests();
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
expect(isCrossSigningReady).toBeFalsy();
});
it("should return true after bootstrapping cross-signing", async () => {
mockSetupCrossSigningRequests();
await bootstrapCrossSigning({ type: "test" });
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
expect(isCrossSigningReady).toBeTruthy();
});
});
});
@@ -21,11 +21,11 @@ import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { MockResponse, MockResponseFunction } from "fetch-mock";
import type { IDeviceKeys } from "../../src/@types/crypto";
import * as testUtils from "../test-utils/test-utils";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { logger } from "../../src/logger";
import type { IDeviceKeys } from "../../../src/@types/crypto";
import * as testUtils from "../../test-utils/test-utils";
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
import { TestClient } from "../../TestClient";
import { logger } from "../../../src/logger";
import {
createClient,
IClaimOTKsResult,
@@ -43,13 +43,16 @@ import {
Room,
RoomMember,
RoomStateEvent,
} from "../../src/matrix";
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";
} from "../../../src/matrix";
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";
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints";
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { CryptoCallbacks } from "../../../src/crypto-api";
const ROOM_ID = "!room:id";
@@ -401,6 +404,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
const newBackendOnly = backend !== "rust-sdk" ? test.skip : test;
const Olm = global.Olm;
@@ -419,12 +423,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
logger.log(aliceClient.getUserId() + ": starting");
const homeserverUrl = aliceClient.getHomeserverUrl();
fetchMock.get(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
fetchMock.get(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
fetchMock.post(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
filter_id: "fid",
});
mockInitialApiRequests(aliceClient.getHomeserverUrl());
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
@@ -532,32 +531,58 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
};
}
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
/**
* Create the {@link CryptoCallbacks}
*/
function createCryptoCallbacks(): CryptoCallbacks {
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
let cachedKey: { keyId: string; key: Uint8Array };
const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
cachedKey = {
keyId,
key,
};
};
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: "@alice:localhost",
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
});
const getSecretStorageKey = () => Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]);
/* set up listeners for /keys/upload and /sync */
keyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
return {
cacheSecretStorageKey,
getSecretStorageKey,
};
}
await initCrypto(aliceClient);
beforeEach(
async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
await Olm.init();
testOlmAccount = new Olm.Account();
testOlmAccount.create();
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
testSenderKey = testE2eKeys.curve25519;
});
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: "@alice:localhost",
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
cryptoCallbacks: createCryptoCallbacks(),
});
/* set up listeners for /keys/upload and /sync */
keyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
await initCrypto(aliceClient);
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
await Olm.init();
testOlmAccount = new Olm.Account();
testOlmAccount.create();
const testE2eKeys = JSON.parse(testOlmAccount.identity_keys());
testSenderKey = testE2eKeys.curve25519;
},
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
10000,
);
afterEach(async () => {
await aliceClient.stopClient();
@@ -1111,7 +1136,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
} catch (e) {
expect((e as any).name).toEqual("UnknownDeviceError");
expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]);
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID"));
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID")).toBeTruthy();
}
// mark the device as known, and resend.
@@ -2173,4 +2198,217 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
);
});
});
describe("bootstrapSecretStorage", () => {
/**
* Create a fake secret storage key
* Async because `bootstrapSecretStorage` expect an async method
*/
const createSecretStorageKey = jest.fn().mockResolvedValue({
keyInfo: {}, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
});
/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)`
* Resolved when a key is uploaded (ie in `body.content.key`)
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
return new Promise((resolve) => {
// This url is called multiple times during the secret storage bootstrap process
// When we received the newly generated key, we return it
fetchMock.put(
"express:/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)",
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
if (content.key) {
resolve(content.key);
}
return {};
},
{ overwriteRoutes: true },
);
});
}
/**
* Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`
* Resolved when the cross signing key is uploaded
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
return new Promise((resolve) => {
// Called when the cross signing key is uploaded
fetchMock.put(
`express:/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
resolve(content.encrypted);
return {};
},
);
});
}
/**
* Send in the sync response the provided `secretStorageKey` into the account_data field
* The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events
* https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync
* @param secretStorageKey
*/
function sendSyncResponse(secretStorageKey: string) {
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: [
{
type: "m.secret_storage.default_key",
content: {
key: secretStorageKey,
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
},
},
// Needed for secretStorage.getKey or secretStorage.hasKey
{
type: `m.secret_storage.key.${secretStorageKey}`,
content: {
key: secretStorageKey,
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
},
},
],
},
});
}
beforeEach(async () => {
createSecretStorageKey.mockClear();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
});
newBackendOnly(
"should throw an error if we are unable to create a key because createSecretStorageKey is not set",
async () => {
await expect(
aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }),
).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set");
},
);
newBackendOnly("should create a new key", async () => {
const bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
// Finally, wait for bootstrapSecretStorage to finished
await bootstrapPromise;
const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId();
// Check that the uploaded key in stored in the secret storage
expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy();
// Check that the uploaded key is the default key
expect(defaultKeyId).toBe(secretStorageKey);
});
newBackendOnly(
"should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set",
async () => {
const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// Call again bootstrapSecretStorage
await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey });
// createSecretStorageKey should be called only on the first run of bootstrapSecretStorage
expect(createSecretStorageKey).toHaveBeenCalledTimes(1);
},
);
newBackendOnly(
"should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage",
async () => {
let bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
// Wait for the key to be uploaded in the account data
let secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// Call again bootstrapSecretStorage
bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
// Wait for the key to be uploaded in the account data
secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call
expect(createSecretStorageKey).toHaveBeenCalledTimes(2);
},
);
newBackendOnly("should upload cross signing keys", async () => {
mockSetupCrossSigningRequests();
// Before setting up secret-storage, bootstrap cross-signing, so that the client has cross-signing keys.
await aliceClient.getCrypto()?.bootstrapCrossSigning({});
// Now, when we bootstrap secret-storage, the cross-signing keys should be uploaded.
const bootstrapPromise = aliceClient
.getCrypto()!
.bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey });
// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponse(secretStorageKey);
// Wait for the cross signing keys to be uploaded
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
awaitCrossSigningKeyUpload("master"),
awaitCrossSigningKeyUpload("user_signing"),
awaitCrossSigningKeyUpload("self_signing"),
]);
// Finally, wait for bootstrapSecretStorage to finished
await bootstrapPromise;
// Expect the cross signing master key to be uploaded and to be encrypted with `secretStorageKey`
expect(masterKey[secretStorageKey]).toBeDefined();
expect(userSigningKey[secretStorageKey]).toBeDefined();
expect(selfSigningKey[secretStorageKey]).toBeDefined();
});
});
});
@@ -16,12 +16,12 @@ limitations under the License.
import { Account } from "@matrix-org/olm";
import { logger } from "../../src/logger";
import { decodeRecoveryKey } from "../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup";
import { TestClient } from "../TestClient";
import { IEvent } from "../../src";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { logger } from "../../../src/logger";
import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
import { TestClient } from "../../TestClient";
import { IEvent } from "../../../src";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
const ROOM_ID = "!ROOM:ID";
@@ -26,16 +26,16 @@ limitations under the License.
*/
// load olm before the sdk if possible
import "../olm-loader";
import "../../olm-loader";
import type { Session } from "@matrix-org/olm";
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
import { logger } from "../../src/logger";
import * as testUtils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto";
import { logger } from "../../../src/logger";
import * as testUtils from "../../test-utils/test-utils";
import { TestClient } from "../../TestClient";
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
let aliTestClient: TestClient;
const roomId = "!room:localhost";
@@ -17,7 +17,7 @@ limitations under the License.
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { createClient } from "../../src";
import { createClient } from "../../../src";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
+641
View File
@@ -0,0 +1,641 @@
/*
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 { MockResponse } from "fetch-mock";
import fetchMock from "fetch-mock-jest";
import { IDBFactory } from "fake-indexeddb";
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient } from "../../../src";
import {
canAcceptVerificationRequest,
ShowQrCodeCallbacks,
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
VerificationRequestEvent,
Verifier,
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { escapeRegExp } from "../../../src/utils";
import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils";
import { SyncResponder } from "../../test-utils/SyncResponder";
import {
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
SIGNED_CROSS_SIGNING_KEYS_DATA,
SIGNED_TEST_DEVICE_DATA,
TEST_DEVICE_ID,
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
TEST_USER_ID,
} from "../../test-utils/test-data";
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
jest.useFakeTimers();
let previousCrypto: Crypto | undefined;
beforeAll(async () => {
// Stub out global.crypto
previousCrypto = global["crypto"];
Object.defineProperty(global, "crypto", {
value: {
getRandomValues: function <T extends Uint8Array>(array: T): T {
array.fill(0x12);
return array;
},
},
});
// we use the libolm primitives in the test, so init the Olm library
await global.Olm.init();
});
// load the rust library. This can take a few seconds on a slow GH worker.
beforeAll(async () => {
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-js");
await RustSdkCryptoJs.initAsync();
}, 10000);
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
// restore the original global.crypto
afterAll(() => {
if (previousCrypto === undefined) {
// @ts-ignore deleting a non-optional property. It *is* optional really.
delete global.crypto;
} else {
Object.defineProperty(global, "crypto", {
value: previousCrypto,
});
}
});
/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */
const TEST_HOMESERVER_URL = "https://alice-server.com";
/**
* Integration tests for verification 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.
*/
// we test with both crypto stacks...
describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => {
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
// Rust backend. Once we have full support in the rust sdk, it will go away.
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
/** the client under test */
let aliceClient: MatrixClient;
/** an object which intercepts `/sync` requests on the test homeserver */
let syncResponder: SyncResponder;
/** an object which intercepts `/keys/query` requests on the test homeserver */
let e2eKeyResponder: E2EKeyResponder;
/** an object which intercepts `/keys/upload` requests on the test homeserver */
let e2eKeyReceiver: E2EKeyReceiver;
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL);
e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL);
e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver);
syncResponder = new SyncResponder(TEST_HOMESERVER_URL);
mockInitialApiRequests(TEST_HOMESERVER_URL);
});
afterEach(async () => {
if (aliceClient !== undefined) {
await aliceClient.stopClient();
}
// Allow in-flight things to complete before we tear down the test
await jest.runAllTimersAsync();
fetchMock.mockReset();
});
describe("Outgoing verification requests for another device", () => {
beforeEach(async () => {
// pretend that we have another device, which we will verify
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
});
// test with (1) the default verification method list, (2) a custom verification method list.
const TEST_METHODS = ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"];
it.each([undefined, TEST_METHODS])("can verify via SAS (supported methods=%s)", async (methods) => {
aliceClient = await startTestClient({ verificationMethods: methods });
await waitForDeviceList();
// initially there should be no verifications in progress
{
const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requests.length).toEqual(0);
}
// have alice initiate a verification. She should send a m.key.verification.request
let [requestBody, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
expect(transactionId).toBeDefined();
expect(request.phase).toEqual(VerificationPhase.Requested);
expect(request.roomId).toBeUndefined();
expect(request.isSelfVerification).toBe(true);
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(false); // no reply yet
expect(request.chosenMethod).toBe(null); // nothing chosen yet
expect(request.initiatedByMe).toBe(true);
expect(request.otherUserId).toEqual(TEST_USER_ID);
// and now the request should be visible via `getVerificationRequestsToDeviceInProgress`
{
const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requests.length).toEqual(1);
expect(requests[0].transactionId).toEqual(transactionId);
}
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
if (methods !== undefined) {
// eslint-disable-next-line jest/no-conditional-expect
expect(new Set(toDeviceMessage.methods)).toEqual(new Set(methods));
}
// The dummy device replies with an m.key.verification.ready...
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID);
// ... and picks a method with m.key.verification.start
returnToDeviceMessageFromSync(buildSasStartMessage(transactionId));
// as soon as the Changed event arrives, `verifier` should be defined
const verifier = await new Promise<Verifier>((resolve) => {
function onChange() {
expect(request.phase).toEqual(VerificationPhase.Started);
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true);
expect(request.chosenMethod).toEqual("m.sas.v1");
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
expect(verifier.getShowSasCallbacks()).toBeNull();
resolve(verifier);
}
request.once(VerificationRequestEvent.Change, onChange);
});
// start off the verification process: alice will send an `accept`
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept");
const verificationPromise = verifier.verify();
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
jest.advanceTimersByTime(10);
requestBody = await sendToDevicePromise;
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519-hkdf-sha256");
expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "emoji"]);
const macMethod = toDeviceMessage.message_authentication_code;
expect(macMethod).toEqual("hkdf-hmac-sha256.v2");
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
const olmSAS = new global.Olm.SAS();
returnToDeviceMessageFromSync({
type: "m.key.verification.key",
content: {
transaction_id: transactionId,
key: olmSAS.get_pubkey(),
},
});
// alice responds with a 'key' ...
requestBody = await expectSendToDeviceMessage("m.key.verification.key");
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
const aliceDevicePubKeyBase64 = toDeviceMessage.key;
olmSAS.set_their_key(aliceDevicePubKeyBase64);
// ... and the client is notified to show the emoji
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
verifier.once(VerifierEvent.ShowSas, resolve);
});
// `getShowSasCallbacks` is an alternative way to get the callbacks
expect(verifier.getShowSasCallbacks()).toBe(showSas);
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
// user confirms that the emoji match, and alice sends a 'mac'
[requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]);
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
// the dummy device also confirms that the emoji match, and sends a mac
const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`;
returnToDeviceMessageFromSync({
type: "m.key.verification.mac",
content: {
keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`),
transaction_id: transactionId,
mac: {
[`ed25519:${TEST_DEVICE_ID}`]: calculateMAC(
olmSAS,
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
`${macInfoBase}ed25519:${TEST_DEVICE_ID}`,
),
},
},
});
// that should satisfy Alice, who should reply with a 'done'
await expectSendToDeviceMessage("m.key.verification.done");
// the dummy device also confirms done-ness
returnToDeviceMessageFromSync({
type: "m.key.verification.done",
content: {
transaction_id: transactionId,
},
});
// ... and the whole thing should be done!
await verificationPromise;
expect(request.phase).toEqual(VerificationPhase.Done);
// at this point, cancelling should do nothing.
await request.cancel();
expect(request.phase).toEqual(VerificationPhase.Done);
// we're done with the temporary keypair
olmSAS.free();
});
it("Can make a verification request to *all* devices", async () => {
aliceClient = await startTestClient();
// we need an existing cross-signing key for this
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
await waitForDeviceList();
// have alice initiate a verification. She should send a m.key.verification.request
const [requestBody, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestOwnUserVerification(),
]);
const transactionId = request.transactionId;
expect(transactionId).toBeDefined();
expect(request.phase).toEqual(VerificationPhase.Requested);
// and now the request should be visible via `getVerificationRequestsToDeviceInProgress`
{
const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID);
expect(requests.length).toEqual(1);
expect(requests[0].transactionId).toEqual(transactionId);
}
// legacy crypto picks devices individually; rust crypto uses a broadcast message
const toDeviceMessage =
requestBody.messages[TEST_USER_ID]["*"] ?? requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
});
oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => {
aliceClient = await startTestClient();
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
await waitForDeviceList();
expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy();
// have alice initiate a verification. She should send a m.key.verification.request
const [requestBody, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.methods).toContain("m.qr_code.show.v1");
expect(toDeviceMessage.methods).toContain("m.reciprocate.v1");
expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1");
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
// The dummy device replies with an m.key.verification.ready, with an indication we can scan the QR code
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.scan.v1"]));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Ready);
// we should now have QR data we can display
const qrCodeBuffer = request.getQRCodeBytes()!;
expect(qrCodeBuffer).toBeTruthy();
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
expect(qrCodeBuffer.subarray(0, 6).toString("latin1")).toEqual("MATRIX");
expect(qrCodeBuffer.readUint8(6)).toEqual(0x02); // version
expect(qrCodeBuffer.readUint8(7)).toEqual(0x02); // mode
const txnIdLen = qrCodeBuffer.readUint16BE(8);
expect(qrCodeBuffer.subarray(10, 10 + txnIdLen).toString("utf-8")).toEqual(transactionId);
// Alice's device's public key comes next, but we have nothing to do with it here.
// const aliceDevicePubKey = qrCodeBuffer.subarray(10 + txnIdLen, 32 + 10 + txnIdLen);
expect(qrCodeBuffer.subarray(42 + txnIdLen, 32 + 42 + txnIdLen)).toEqual(
Buffer.from(MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64, "base64"),
);
const sharedSecret = qrCodeBuffer.subarray(74 + txnIdLen);
// the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start"
returnToDeviceMessageFromSync({
type: "m.key.verification.start",
content: {
from_device: TEST_DEVICE_ID,
method: "m.reciprocate.v1",
transaction_id: transactionId,
secret: encodeUnpaddedBase64(sharedSecret),
},
});
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Started);
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
// there should now be a verifier
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
// ... which we call .verify on, which emits a ShowReciprocateQr event
const verificationPromise = verifier.verify();
const reciprocateQRCodeCallbacks = await new Promise<ShowQrCodeCallbacks>((resolve) => {
verifier.once(VerifierEvent.ShowReciprocateQr, resolve);
});
// getReciprocateQrCodeCallbacks() is an alternative way to get the callbacks
expect(verifier.getReciprocateQrCodeCallbacks()).toBe(reciprocateQRCodeCallbacks);
expect(verifier.getShowSasCallbacks()).toBeNull();
// Alice confirms she is happy
reciprocateQRCodeCallbacks.confirm();
// that should satisfy Alice, who should reply with a 'done'
await expectSendToDeviceMessage("m.key.verification.done");
// ... and the whole thing should be done!
await verificationPromise;
expect(request.phase).toEqual(VerificationPhase.Done);
});
});
describe("cancellation", () => {
beforeEach(async () => {
// pretend that we have another device, which we will start verifying
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
aliceClient = await startTestClient();
await waitForDeviceList();
});
it("can cancel during the Ready phase", async () => {
// have alice initiate a verification. She should send a m.key.verification.request
const [, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
// The dummy device replies with an m.key.verification.ready...
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
await waitForVerificationRequestChanged(request);
// now alice changes her mind
const [requestBody] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.cancel"),
request.cancel(),
]);
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
expect(toDeviceMessage.code).toEqual("m.user");
expect(request.phase).toEqual(VerificationPhase.Cancelled);
});
it("can cancel during the SAS phase", async () => {
// have alice initiate a verification. She should send a m.key.verification.request
const [, request] = await Promise.all([
expectSendToDeviceMessage("m.key.verification.request"),
aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID),
]);
const transactionId = request.transactionId!;
// The dummy device replies with an m.key.verification.ready...
returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"]));
await waitForVerificationRequestChanged(request);
// ... and picks a method with m.key.verification.start
returnToDeviceMessageFromSync(buildSasStartMessage(transactionId));
await waitForVerificationRequestChanged(request);
expect(request.phase).toEqual(VerificationPhase.Started);
// there should now be a verifier...
const verifier: Verifier = request.verifier!;
expect(verifier).toBeDefined();
expect(verifier.hasBeenCancelled).toBe(false);
// start off the verification process: alice will send an `accept`
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept");
const verificationPromise = verifier.verify();
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
jest.advanceTimersByTime(10);
await sendToDevicePromise;
// now we unceremoniously cancel. We expect the verificatationPromise to reject.
const requestPromise = expectSendToDeviceMessage("m.key.verification.cancel");
verifier.cancel(new Error("blah"));
await requestPromise;
// ... which should cancel the verifier
await expect(verificationPromise).rejects.toThrow();
expect(request.phase).toEqual(VerificationPhase.Cancelled);
expect(verifier.hasBeenCancelled).toBe(true);
});
});
describe("Incoming verification from another device", () => {
beforeEach(() => {
e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA);
});
oldBackendOnly("Incoming verification: can accept", async () => {
aliceClient = await startTestClient();
const TRANSACTION_ID = "abcd";
// Initiate the request by sending a to-device message
returnToDeviceMessageFromSync({
type: "m.key.verification.request",
content: {
from_device: TEST_DEVICE_ID,
methods: ["m.sas.v1"],
transaction_id: TRANSACTION_ID,
timestamp: Date.now() - 1000,
},
});
const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest);
expect(request.transactionId).toEqual(TRANSACTION_ID);
expect(request.phase).toEqual(VerificationPhase.Requested);
expect(request.roomId).toBeUndefined();
expect(request.initiatedByMe).toBe(false);
expect(request.otherUserId).toEqual(TEST_USER_ID);
expect(request.chosenMethod).toBe(null); // nothing chosen yet
expect(canAcceptVerificationRequest(request)).toBe(true);
// Alice accepts, by sending a to-device message
const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready");
const acceptPromise = request.accept();
expect(canAcceptVerificationRequest(request)).toBe(false);
expect(request.phase).toEqual(VerificationPhase.Requested);
await acceptPromise;
const requestBody = await sendToDevicePromise;
expect(request.phase).toEqual(VerificationPhase.Ready);
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage.methods).toContain("m.sas.v1");
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID);
});
});
async function startTestClient(opts: Partial<ICreateClientOpts> = {}): Promise<MatrixClient> {
const client = createClient({
baseUrl: TEST_HOMESERVER_URL,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: "device_under_test",
...opts,
});
await initCrypto(client);
await client.startClient();
return client;
}
/** make sure that the client knows about the dummy device */
async function waitForDeviceList(): Promise<void> {
// Completing the initial sync will make the device list download outdated device lists (of which our own
// user will be one).
syncResponder.sendOrQueueSyncResponse({});
// DeviceList has a sleep(5) which we need to make happen
await jest.advanceTimersByTimeAsync(10);
// The client should now know about the dummy device
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID);
}
function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void {
ev.sender ??= TEST_USER_ID;
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } });
}
});
/**
* Wait for the client under test to send a to-device message of the given type.
*
* @param msgtype - type of to-device message we expect
* @returns A Promise which resolves with the body of the HTTP request
*/
function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }> {
return new Promise((resolve) => {
fetchMock.putOnce(
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp(msgtype)}`),
(url: string, opts: RequestInit): MockResponse => {
resolve(JSON.parse(opts.body as string));
return {};
},
);
});
}
/** wait for the verification request to emit a 'Change' event */
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
return new Promise<void>((resolve) => {
request.once(VerificationRequestEvent.Change, resolve);
});
}
/** Perform a MAC calculation on the given data
*
* Does an HKDR and HMAC as defined by the matrix spec (https://spec.matrix.org/v1.7/client-server-api/#mac-calculation,
* as amended by https://github.com/matrix-org/matrix-spec/issues/1553).
*
* @param olmSAS
* @param input
* @param info
*/
function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string {
const mac = olmSAS.calculate_mac_fixed_base64(input, info);
//console.info(`Test MAC: input:'${input}, info: '${info}' -> '${mac}`);
return mac;
}
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
}
/** build an m.key.verification.ready to-device message originating from the dummy device */
function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } {
return {
type: "m.key.verification.ready",
content: {
from_device: TEST_DEVICE_ID,
methods: methods,
transaction_id: transactionId,
},
};
}
/** build an m.key.verification.start to-device message suitable for the SAS flow, originating from the dummy device */
function buildSasStartMessage(transactionId: string): { type: string; content: object } {
return {
type: "m.key.verification.start",
content: {
from_device: TEST_DEVICE_ID,
method: "m.sas.v1",
transaction_id: transactionId,
hashes: ["sha256"],
key_agreement_protocols: ["curve25519-hkdf-sha256"],
message_authentication_codes: ["hkdf-hmac-sha256.v2"],
// we have to include "decimal" per the spec.
short_authentication_string: ["decimal", "emoji"],
},
};
}
+139 -14
View File
@@ -1142,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",
@@ -1156,7 +1156,7 @@ 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([
@@ -1248,7 +1248,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",
@@ -1267,13 +1267,14 @@ 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_ROOT_REACTION.getId(),
THREAD_REPLY3.getId(),
]);
});
@@ -1322,7 +1323,7 @@ describe("MatrixClient event timelines", function () {
request.respond(200, function () {
return {
original_event: root,
chunk: [replies],
chunk: replies,
// no next batch as this is the oldest end of the timeline
};
});
@@ -1452,7 +1453,7 @@ describe("MatrixClient event timelines", function () {
expect(room.getPendingEvents()).toHaveLength(1);
});
it("should handle thread updates by reordering the thread list", async () => {
it("should handle new thread replies by reordering the thread list", async () => {
// Test data for a second thread
const THREAD2_ROOT = utils.mkEvent({
room: roomId,
@@ -1479,7 +1480,7 @@ describe("MatrixClient event timelines", function () {
user: userId,
type: "m.room.message",
content: {
"body": "thread reply",
"body": "thread2 reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
@@ -1499,7 +1500,7 @@ describe("MatrixClient event timelines", function () {
user: userId,
type: "m.room.message",
content: {
"body": "thread reply",
"body": "thread reply2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
@@ -1571,8 +1572,9 @@ describe("MatrixClient event timelines", function () {
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD2_ROOT);
room.addLiveEvents([THREAD_REPLY2]);
await room.addLiveEvents([THREAD_REPLY2]);
await httpBackend.flushAllExpected();
await prom;
expect(thread.length).toBe(2);
@@ -1582,6 +1584,134 @@ describe("MatrixClient event timelines", function () {
THREAD_ROOT.event_id,
]);
});
it("should not reorder the thread list on other thread updates", async () => {
// Test data for a second thread
const THREAD2_ROOT = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
body: "thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
"io.element.thread": {
//"latest_event": undefined,
count: 1,
current_user_participated: true,
},
},
},
event: false,
});
const THREAD2_REPLY = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread2 reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: false,
});
// @ts-ignore we know this is a defined path for THREAD ROOT
THREAD2_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD2_REPLY;
const THREAD_REPLY_REACTION = utils.mkEvent({
room: roomId,
user: userId,
type: "m.reaction",
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: THREAD_REPLY.event_id,
key: "🪿",
},
},
event: true,
});
THREAD_REPLY_REACTION.localTimestamp += 1000;
// Modified thread root event containing latest thread reply in its unsigned
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 2,
latest_event: THREAD_REPLY,
},
},
},
};
// Response with test data for the thread list request
const threadsResponse = {
chunk: [THREAD2_ROOT, THREAD_ROOT],
state: [],
next_batch: RANDOM_TOKEN as string | null,
};
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
// Set up room threads
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
respondToThreads(threadsResponse);
respondToThreads(threadsResponse);
respondToEvent(THREAD_ROOT);
respondToEvent(THREAD2_ROOT);
respondToThread(THREAD_ROOT, [THREAD_REPLY]);
respondToThread(THREAD2_ROOT, [THREAD2_REPLY]);
await flushHttp(room.fetchRoomThreads());
const threadIds = room.getThreads().map((thread) => thread.id);
expect(threadIds).toContain(THREAD_ROOT.event_id);
expect(threadIds).toContain(THREAD2_ROOT.event_id);
const [allThreads] = timelineSets!;
const timeline = allThreads.getLiveTimeline()!;
// Test threads are in chronological order
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
]);
// Test adding a second event to the first thread
const thread = room.getThread(THREAD_ROOT.event_id!)!;
thread.initialEventsFetched = true;
const prom = emitPromise(room, ThreadEvent.Update);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD2_ROOT);
await room.addLiveEvents([THREAD_REPLY_REACTION]);
await httpBackend.flushAllExpected();
await prom;
expect(thread.length).toBe(2);
// Test thread order is unchanged
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
]);
});
});
describe("without server compatibility", function () {
@@ -1937,11 +2067,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 () {
+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);
+72 -84
View File
@@ -20,7 +20,7 @@ import { fail } from "assert";
import { SlidingSync, SlidingSyncEvent, MSC3575RoomData, SlidingSyncState, Extension } from "../../src/sliding-sync";
import { TestClient } from "../TestClient";
import { IRoomEvent, IStateEvent } from "../../src/sync-accumulator";
import { IRoomEvent, IStateEvent } from "../../src";
import {
MatrixClient,
MatrixEvent,
@@ -39,9 +39,10 @@ import {
} from "../../src";
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
import { SyncApiOptions, SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client";
import { IStoredClientOpts } from "../../src";
import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils";
import { defer } from "../../src/utils";
describe("SlidingSyncSdk", () => {
let client: MatrixClient | undefined;
@@ -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);
});
@@ -723,7 +708,7 @@ describe("SlidingSyncSdk", () => {
],
});
globalData = client!.getAccountData(globalType)!;
expect(globalData).toBeDefined();
expect(globalData).toBeTruthy();
expect(globalData.getContent()).toEqual(globalContent);
});
@@ -744,6 +729,7 @@ describe("SlidingSyncSdk", () => {
foo: "bar",
};
const roomType = "test";
await emitPromise(client!, ClientEvent.Room);
ext.onResponse({
rooms: {
[roomId]: [
@@ -755,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);
});
@@ -943,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: {
@@ -984,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: {
@@ -1077,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
@@ -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);
}
+7
View File
@@ -145,6 +145,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
return this.deviceKeys.keys[keyIds[0]];
}
/**
* If the device keys have already been uploaded, return them. Else return null.
*/
public getUploadedDeviceKeys(): IDeviceKeys | null {
return this.deviceKeys;
}
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
+123
View File
@@ -0,0 +1,123 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import { MapWithDefault } from "../../src/utils";
import { IDownloadKeyResult } from "../../src";
import { IDeviceKeys } from "../../src/@types/crypto";
import { E2EKeyReceiver } from "./E2EKeyReceiver";
/**
* An object which intercepts `/keys/query` fetches via fetch-mock.
*/
export class E2EKeyResponder {
private deviceKeysByUserByDevice = new MapWithDefault<string, Map<string, any>>(() => new Map());
private e2eKeyReceiversByUser = new Map<string, E2EKeyReceiver>();
private masterKeysByUser: Record<string, any> = {};
private selfSigningKeysByUser: Record<string, any> = {};
private userSigningKeysByUser: Record<string, any> = {};
/**
* Construct a new E2EKeyResponder.
*
* It will immediately register an intercept of `/keys/query` requests for the given homeserverUrl.
* Only /query requests made to this server will be intercepted: this allows a single test to use more than one
* client and have the keys collected separately.
*
* @param homeserverUrl - the Homeserver Url of the client under test.
*/
public constructor(homeserverUrl: string) {
// set up a listener for /keys/query.
const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options);
// catch both r0 and v3 variants
fetchMock.post(new URL("/_matrix/client/r0/keys/query", homeserverUrl).toString(), listener);
fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener);
}
private onKeyQueryRequest(options: RequestInit) {
const content = JSON.parse(options.body as string);
const usersToReturn = Object.keys(content["device_keys"]);
const response = {
device_keys: {} as { [userId: string]: any },
master_keys: {} as { [userId: string]: any },
self_signing_keys: {} as { [userId: string]: any },
user_signing_keys: {} as { [userId: string]: any },
failures: {} as { [serverName: string]: any },
};
for (const user of usersToReturn) {
const userKeys = this.deviceKeysByUserByDevice.get(user);
if (userKeys !== undefined) {
response.device_keys[user] = Object.fromEntries(userKeys.entries());
}
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
if (e2eKeyReceiver !== undefined) {
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
if (deviceKeys !== null) {
response.device_keys[user] ??= {};
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
}
}
if (this.masterKeysByUser.hasOwnProperty(user)) {
response.master_keys[user] = this.masterKeysByUser[user];
}
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
}
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
}
}
return response;
}
/**
* Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed
*
* @param userId - user the keys belong to
* @param deviceId - device the keys belong to
* @param keys - device keys for this device.
*/
public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) {
this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys);
}
/** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed
*
* @param data cross-signing data
*/
public addCrossSigningData(
data: Pick<IDownloadKeyResult, "master_keys" | "self_signing_keys" | "user_signing_keys">,
) {
Object.assign(this.masterKeysByUser, data.master_keys);
Object.assign(this.selfSigningKeysByUser, data.self_signing_keys);
Object.assign(this.userSigningKeysByUser, data.user_signing_keys);
}
/**
* Add an E2EKeyReceiver to poll for uploaded keys
*
* Any keys which have been uploaded to the given `E2EKeyReceiver` at the time of the `/keys/query` request will
* be added to the response.
*
* @param e2eKeyReceiver
*/
public addKeyReceiver(userId: string, e2eKeyReceiver: E2EKeyReceiver) {
this.e2eKeyReceiversByUser.set(userId, e2eKeyReceiver);
}
}
+55
View File
@@ -0,0 +1,55 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
/**
* Mock out the endpoints that the js-sdk calls when we call `MatrixClient.start()`.
*
* @param homeserverUrl - the homeserver url for the client under test
*/
export function mockInitialApiRequests(homeserverUrl: string) {
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
fetchMock.getOnce(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
fetchMock.postOnce(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
filter_id: "fid",
});
}
/**
* Mock the requests needed to set up cross signing
*
* Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
*/
export function mockSetupCrossSigningRequests(): void {
// have account_data requests return an empty object
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
// ... and one to upload the cross-signing keys (with UIA)
fetchMock.post(
// legacy crypto uses /unstable/; /v3/ is correct
{
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
name: "upload-keys",
},
{},
);
}
+1
View File
@@ -0,0 +1 @@
/env
+203
View File
@@ -0,0 +1,203 @@
#!/bin/env python
#
# Copyright 2023 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
This file is a Python script to generate test data for crypto tests.
To run it:
python -m venv env
./env/bin/pip install cryptography canonicaljson
./env/bin/python generate-test-data.py > index.ts
"""
import base64
import json
from canonicaljson import encode_canonical_json
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
# input data
TEST_USER_ID = "@alice:localhost"
TEST_DEVICE_ID = "test_device"
# any 32-byte string can be an ed25519 private key.
TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef"
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale"
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"
def main() -> None:
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
TEST_DEVICE_PRIVATE_KEY_BYTES
)
b64_public_key = encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
device_data = {
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
"device_id": TEST_DEVICE_ID,
"keys": {
f"curve25519:{TEST_DEVICE_ID}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
f"ed25519:{TEST_DEVICE_ID}": b64_public_key,
},
"signatures": {TEST_USER_ID: {}},
"user_id": TEST_USER_ID,
}
device_data["signatures"][TEST_USER_ID][f"ed25519:{TEST_DEVICE_ID}"] = sign_json(
device_data, private_key
)
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_master_public_key = encode_base64(
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
print(
f"""\
/* Test data for cryptography tests
*
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult }} from "../../../src";
/* eslint-disable comma-dangle */
export const TEST_USER_ID = "{TEST_USER_ID}";
export const TEST_DEVICE_ID = "{TEST_DEVICE_ID}";
/** The base64-encoded public ed25519 key for this device */
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
/** Signed device data, suitable for returning from a `/keys/query` call */
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
/** base64-encoded public master cross-signing key */
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
json.dumps(build_cross_signing_keys_data(), indent=4)
};
""",
end="",
)
def build_cross_signing_keys_data() -> dict:
"""Build the signed cross-signing-keys data for return from /keys/query"""
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_master_public_key = encode_base64(
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_self_signing_public_key = encode_base64(
self_signing_private_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw
)
)
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
)
b64_user_signing_public_key = encode_base64(
user_signing_private_key.public_key().public_bytes(
Encoding.Raw, PublicFormat.Raw
)
)
# create without signatures initially
cross_signing_keys_data = {
"master_keys": {
TEST_USER_ID: {
"keys": {
f"ed25519:{b64_master_public_key}": b64_master_public_key,
},
"user_id": TEST_USER_ID,
"usage": ["master"],
}
},
"self_signing_keys": {
TEST_USER_ID: {
"keys": {
f"ed25519:{b64_self_signing_public_key}": b64_self_signing_public_key,
},
"user_id": TEST_USER_ID,
"usage": ["self_signing"],
},
},
"user_signing_keys": {
TEST_USER_ID: {
"keys": {
f"ed25519:{b64_user_signing_public_key}": b64_user_signing_public_key,
},
"user_id": TEST_USER_ID,
"usage": ["user_signing"],
},
},
}
# sign the sub-keys with the master
for k in ["self_signing_keys", "user_signing_keys"]:
to_sign = cross_signing_keys_data[k][TEST_USER_ID]
sig = sign_json(to_sign, master_private_key)
to_sign["signatures"] = {
TEST_USER_ID: {f"ed25519:{b64_master_public_key}": sig}
}
return cross_signing_keys_data
def encode_base64(input_bytes: bytes) -> str:
"""Encode with unpadded base64"""
output_bytes = base64.b64encode(input_bytes)
output_string = output_bytes.decode("ascii")
return output_string.rstrip("=")
def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
"""
Sign the given json object
Returns the base64-encoded signature of signing `input` following the Matrix
JSON signature algorithm [1]
[1]: https://spec.matrix.org/v1.7/appendices/#signing-details
"""
signatures = json_object.pop("signatures", {})
unsigned = json_object.pop("unsigned", None)
signature = private_key.sign(encode_canonical_json(json_object))
signature_base64 = encode_base64(signature)
json_object["signatures"] = signatures
if unsigned is not None:
json_object["unsigned"] = unsigned
return signature_base64
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
/* Test data for cryptography tests
*
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import { IDeviceKeys } from "../../../src/@types/crypto";
import { IDownloadKeyResult } from "../../../src";
/* eslint-disable comma-dangle */
export const TEST_USER_ID = "@alice:localhost";
export const TEST_DEVICE_ID = "test_device";
/** The base64-encoded public ed25519 key for this device */
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU";
/** Signed device data, suitable for returning from a `/keys/query` call */
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"device_id": "test_device",
"keys": {
"curve25519:test_device": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
"ed25519:test_device": "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU"
},
"user_id": "@alice:localhost",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "LmQC/yAUZJmkxZ+3L0nEwvtVWOzjqQqADWBhk+C47SPaFYHeV+E291mgXaSCJVeGltX+HC49Aw7nb6ga7sw0Aw"
}
}
};
/** base64-encoded public master cross-signing key */
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY";
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
"master_keys": {
"@alice:localhost": {
"keys": {
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY"
},
"user_id": "@alice:localhost",
"usage": [
"master"
]
}
},
"self_signing_keys": {
"@alice:localhost": {
"keys": {
"ed25519:aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY": "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY"
},
"user_id": "@alice:localhost",
"usage": [
"self_signing"
],
"signatures": {
"@alice:localhost": {
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "XfhYEhZmOs8BJdb3viatILBZ/bElsHXEW28V4tIaY5CxrBR0YOym3yZHWmRmypXessHZAKOhZn3yBMXzdajyCw"
}
}
}
},
"user_signing_keys": {
"@alice:localhost": {
"keys": {
"ed25519:g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY": "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY"
},
"user_id": "@alice:localhost",
"usage": [
"user_signing"
],
"signatures": {
"@alice:localhost": {
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "6AkD1XM2H0/ebgP9oBdMKNeft7uxsrb0XN1CsjjHgeZCvCTMmv3BHlLiT/Hzy4fe8H+S1tr484dcXN/PIdnfDA"
}
}
}
}
};
+72 -1
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
*/
+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,
+75
View File
@@ -745,3 +745,78 @@ export const REMOTE_SFU_DESCRIPTION =
"a=sctp-port:5000\n" +
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs";
export const groupCallParticipantsFourOtherDevices = new Map([
[
new RoomMember("roomId0", "user1"),
new Map([
[
"deviceId0",
{
sessionId: "0",
screensharing: false,
},
],
[
"deviceId1",
{
sessionId: "1",
screensharing: false,
},
],
[
"deviceId2",
{
sessionId: "2",
screensharing: false,
},
],
]),
],
[
new RoomMember("roomId0", "user2"),
new Map([
[
"deviceId3",
{
sessionId: "0",
screensharing: false,
},
],
[
"deviceId4",
{
sessionId: "1",
screensharing: false,
},
],
]),
],
]);
export const groupCallParticipantsOneOtherDevice = new Map([
[
new RoomMember("roomId1", "thisMember"),
new Map([
[
"deviceId0",
{
sessionId: "0",
screensharing: false,
},
],
]),
],
[
new RoomMember("roomId1", "opponentMember"),
new Map([
[
"deviceId1",
{
sessionId: "1",
screensharing: false,
},
],
]),
],
]);
File diff suppressed because it is too large Load Diff
+64 -3
View File
@@ -18,6 +18,7 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import { AutoDiscovery } from "../../src/autodiscovery";
import { OidcError } from "../../src/oidc/error";
describe("AutoDiscovery", function () {
const getHttpBackend = (): MockHttpBackend => {
@@ -368,7 +369,7 @@ describe("AutoDiscovery", function () {
},
);
it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + "m.homeserver", function () {
it("should return SUCCESS when .well-known has a verifiably accurate base_url for m.homeserver", function () {
const httpBackend = getHttpBackend();
httpBackend
.when("GET", "/_matrix/client/versions")
@@ -397,6 +398,10 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -434,6 +439,54 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return SUCCESS with authentication error when authentication config is invalid", function () {
const httpBackend = getHttpBackend();
httpBackend
.when("GET", "/_matrix/client/versions")
.check((req) => {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.authentication": {
invalid: true,
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "SUCCESS",
error: null,
base_url: "https://chat.example.org",
},
"m.identity_server": {
state: "PROMPT",
error: null,
base_url: null,
},
"m.authentication": {
state: "FAIL_ERROR",
error: OidcError.Misconfigured,
},
};
expect(conf).toEqual(expected);
@@ -625,7 +678,7 @@ describe("AutoDiscovery", function () {
},
);
it("should return SUCCESS when the identity server configuration is " + "verifiably accurate", function () {
it("should return SUCCESS when the identity server configuration is verifiably accurate", function () {
const httpBackend = getHttpBackend();
httpBackend
.when("GET", "/_matrix/client/versions")
@@ -664,6 +717,10 @@ describe("AutoDiscovery", function () {
error: null,
base_url: "https://identity.example.org",
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -671,7 +728,7 @@ describe("AutoDiscovery", function () {
]);
});
it("should return SUCCESS and preserve non-standard keys from the " + ".well-known response", function () {
it("should return SUCCESS and preserve non-standard keys from the .well-known response", function () {
const httpBackend = getHttpBackend();
httpBackend
.when("GET", "/_matrix/client/versions")
@@ -716,6 +773,10 @@ describe("AutoDiscovery", function () {
"org.example.custom.property": {
cupcakes: "yes",
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
+5 -30
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();
}),
);
@@ -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();
}),
);
+3 -14
View File
@@ -148,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();
});
@@ -214,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();
});
+4 -4
View File
@@ -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,7 +169,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
@@ -519,7 +519,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
@@ -543,7 +543,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
+49 -94
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 * as utils from "../test-utils/test-utils";
import {
DuplicateStrategy,
@@ -24,13 +26,10 @@ import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
RelationType,
Room,
RoomEvent,
} from "../../src";
import { FeatureSupport, Thread } from "../../src/models/thread";
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
import { eventMapperFor } from "../../src/event-mapper";
describe("EventTimelineSet", () => {
const roomId = "!foo:bar";
@@ -145,13 +144,6 @@ describe("EventTimelineSet", () => {
});
describe("addEventToTimeline", () => {
let thread: Thread;
beforeEach(() => {
(client.supportsThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
it("Adds event to timeline", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
@@ -171,6 +163,42 @@ describe("EventTimelineSet", () => {
}).not.toThrow();
});
it("should aggregate relations which belong to unknown timeline without adding them to any timeline", () => {
// If threads are disabled all events go into the main timeline
mocked(client.supportsThreads).mockReturnValue(true);
const reactionEvent = utils.mkReaction(messageEvent, client, client.getSafeUserId(), roomId);
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addEventToTimeline(reactionEvent, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents()).toHaveLength(1);
const [event] = liveTimeline.getEvents();
const reactions = eventTimelineSet.relations!.getChildEventsForEvent(
event.getId()!,
"m.annotation",
"m.reaction",
)!;
const relations = reactions.getRelations();
expect(relations).toHaveLength(1);
expect(relations[0].getId()).toBe(reactionEvent.getId());
});
});
describe("addEventToTimeline (thread timeline)", () => {
let thread: Thread;
beforeEach(() => {
(client.supportsThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
it("should not add an event to a timeline that does not belong to the timelineSet", () => {
const eventTimelineSet2 = new EventTimelineSet(room);
const liveTimeline2 = eventTimelineSet2.getLiveTimeline();
@@ -200,94 +228,19 @@ describe("EventTimelineSet", () => {
const liveTimeline = eventTimelineSetForThread.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSetForThread.addEventToTimeline(messageEvent, liveTimeline, {
const normalMessage = utils.mkMessage({
room: roomId,
user: userA,
msg: "Hello!",
event: true,
});
eventTimelineSetForThread.addEventToTimeline(normalMessage, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "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(
@@ -421,7 +374,9 @@ describe("EventTimelineSet", () => {
});
it("should return true if the timeline set is not for a thread and the event is a thread root", () => {
const thread = new Thread(messageEvent.getId()!, messageEvent, { room, client });
const eventTimelineSet = new EventTimelineSet(room, {}, client);
messageEvent.setThread(thread);
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
});
+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);
});
});
});
+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]);
});
});
+90
View File
@@ -1,6 +1,15 @@
import fetchMock from "fetch-mock-jest";
import { ClientPrefix, MatrixClient } from "../../src";
import { SSOAction } from "../../src/@types/auth";
import { TestClient } from "../TestClient";
function createExampleMatrixClient(): MatrixClient {
return new MatrixClient({
baseUrl: "https://example.com",
});
}
describe("Login request", function () {
let client: TestClient;
@@ -57,3 +66,84 @@ describe("SSO login URL", function () {
});
});
});
describe("refreshToken", () => {
afterEach(() => {
fetchMock.mockReset();
});
it("requests the correctly-prefixed /refresh endpoint when server correctly accepts /v3", async () => {
const client = createExampleMatrixClient();
const response = {
access_token: "access_token",
refresh_token: "refresh_token",
expires_in_ms: 30000,
};
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), response);
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), () => {
throw new Error("/v1/refresh unexpectedly called");
});
const refreshResult = await client.refreshToken("initial_refresh_token");
expect(refreshResult).toEqual(response);
});
it("falls back to /v1 when server does not recognized /v3 refresh", async () => {
const client = createExampleMatrixClient();
const response = {
access_token: "access_token",
refresh_token: "refresh_token",
expires_in_ms: 30000,
};
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
status: 400,
body: { errcode: "M_UNRECOGNIZED" },
});
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), response);
const refreshResult = await client.refreshToken("initial_refresh_token");
expect(refreshResult).toEqual(response);
});
it("re-raises M_UNRECOGNIZED exceptions from /v1", async () => {
const client = createExampleMatrixClient();
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
status: 400,
body: { errcode: "M_UNRECOGNIZED" },
});
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), {
status: 400,
body: { errcode: "M_UNRECOGNIZED" },
});
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
});
it("re-raises non-M_UNRECOGNIZED exceptions from /v3", async () => {
const client = createExampleMatrixClient();
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), 429);
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), () => {
throw new Error("/v1/refresh unexpectedly called");
});
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
});
it("re-raises non-M_UNRECOGNIZED exceptions from /v1", async () => {
const client = createExampleMatrixClient();
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
status: 400,
body: { errcode: "M_UNRECOGNIZED" },
});
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), 429);
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
});
});
+30 -31
View File
@@ -28,7 +28,6 @@ import {
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
MSC3912_RELATION_BASED_REDACTIONS_PROP,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
@@ -181,9 +180,7 @@ describe("MatrixClient", function () {
data: SYNC_DATA,
};
const unstableFeatures: Record<string, boolean> = {
"org.matrix.msc3440.stable": true,
};
let unstableFeatures: Record<string, boolean> = {};
// items are popped off when processed and block if no items left.
let httpLookups: HttpLookup[] = [];
@@ -342,6 +339,12 @@ describe("MatrixClient", function () {
store.getClientOptions = jest.fn().mockReturnValue(Promise.resolve(null));
store.storeClientOptions = jest.fn().mockReturnValue(Promise.resolve(null));
store.isNewlyCreated = jest.fn().mockReturnValue(Promise.resolve(true));
// set unstableFeatures to a defined state before each test
unstableFeatures = {
"org.matrix.msc3440.stable": true,
};
makeClient();
// set reasonable working defaults
@@ -1373,10 +1376,10 @@ describe("MatrixClient", function () {
await client.redactEvent(roomId, eventId, txnId, { reason });
});
describe("when calling with with_relations", () => {
describe("when calling with 'with_rel_types'", () => {
const eventId = "$event42:example.org";
it("should raise an error if server has no support for relation based redactions", async () => {
it("should raise an error if the server has no support for relation based redactions", async () => {
// load supported features
await client.getVersions();
@@ -1384,7 +1387,7 @@ describe("MatrixClient", function () {
expect(() => {
client.redactEvent(roomId, eventId, txnId, {
with_relations: [RelationType.Reference],
with_rel_types: [RelationType.Reference],
});
}).toThrow(
new Error(
@@ -1394,34 +1397,30 @@ describe("MatrixClient", function () {
);
});
describe("and the server supports relation based redactions (unstable)", () => {
beforeEach(async () => {
unstableFeatures["org.matrix.msc3912"] = true;
// load supported features
await client.getVersions();
});
it("and the server has unstable support for relation based redactions, it should send 'org.matrix.msc3912.with_relations' in the request body", async () => {
unstableFeatures["org.matrix.msc3912"] = true;
// load supported features
await client.getVersions();
it("should send with_relations in the request body", async () => {
const txnId = client.makeTxnId();
const txnId = client.makeTxnId();
httpLookups = [
{
method: "PUT",
path:
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
`/${encodeURIComponent(txnId)}`,
expectBody: {
reason: "redaction test",
[MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: [RelationType.Reference],
},
data: { event_id: eventId },
httpLookups = [
{
method: "PUT",
path:
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
`/${encodeURIComponent(txnId)}`,
expectBody: {
reason: "redaction test",
["org.matrix.msc3912.with_relations"]: ["m.reference"],
},
];
data: { event_id: eventId },
},
];
await client.redactEvent(roomId, eventId, txnId, {
reason: "redaction test",
with_relations: [RelationType.Reference],
});
await client.redactEvent(roomId, eventId, txnId, {
reason: "redaction test",
with_rel_types: [RelationType.Reference],
});
});
});
+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;
}
+209
View File
@@ -0,0 +1,209 @@
/*
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 { Method } from "../../../src";
import * as crypto from "../../../src/crypto/crypto";
import { logger } from "../../../src/logger";
import {
completeAuthorizationCodeGrant,
generateAuthorizationParams,
generateAuthorizationUrl,
} from "../../../src/oidc/authorize";
import { OidcError } from "../../../src/oidc/error";
// save for resetting mocks
const realSubtleCrypto = crypto.subtleCrypto;
describe("oidc authorization", () => {
const issuer = "https://auth.com/";
const authorizationEndpoint = "https://auth.com/authorization";
const tokenEndpoint = "https://auth.com/token";
const delegatedAuthConfig = {
issuer,
registrationEndpoint: issuer + "registration",
authorizationEndpoint: issuer + "auth",
tokenEndpoint,
};
const clientId = "xyz789";
const baseUrl = "https://test.com";
beforeAll(() => {
jest.spyOn(logger, "warn");
});
afterEach(() => {
// @ts-ignore reset any ugly mocking we did
crypto.subtleCrypto = realSubtleCrypto;
});
it("should generate authorization params", () => {
const result = generateAuthorizationParams({ redirectUri: baseUrl });
expect(result.redirectUri).toEqual(baseUrl);
// random strings
expect(result.state.length).toEqual(8);
expect(result.nonce.length).toEqual(8);
expect(result.codeVerifier.length).toEqual(64);
const expectedScope =
"openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:";
expect(result.scope.startsWith(expectedScope)).toBeTruthy();
// deviceId of 10 characters is appended to the device scope
expect(result.scope.length).toEqual(expectedScope.length + 10);
});
describe("generateAuthorizationUrl()", () => {
it("should generate url with correct parameters", async () => {
// test the no crypto case here
// @ts-ignore mocking
crypto.subtleCrypto = undefined;
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
const authUrl = new URL(
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
);
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
expect(authUrl.searchParams.get("response_type")).toEqual("code");
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
expect(authUrl.searchParams.get("scope")).toEqual(authorizationParams.scope);
expect(authUrl.searchParams.get("state")).toEqual(authorizationParams.state);
expect(authUrl.searchParams.get("nonce")).toEqual(authorizationParams.nonce);
// crypto not available, plain text code_challenge is used
expect(authUrl.searchParams.get("code_challenge")).toEqual(authorizationParams.codeVerifier);
expect(logger.warn).toHaveBeenCalledWith(
"A secure context is required to generate code challenge. Using plain text code challenge",
);
});
it("uses a s256 code challenge when crypto is available", async () => {
jest.spyOn(crypto.subtleCrypto, "digest");
const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl });
const authUrl = new URL(
await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams),
);
const codeChallenge = authUrl.searchParams.get("code_challenge");
expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object));
// didn't use plain text code challenge
expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge);
expect(codeChallenge).toBeTruthy();
});
});
describe("completeAuthorizationCodeGrant", () => {
const codeVerifier = "abc123";
const redirectUri = baseUrl;
const code = "auth_code_xyz";
const validBearerTokenResponse = {
token_type: "Bearer",
access_token: "test_access_token",
refresh_token: "test_refresh_token",
expires_in: 12345,
};
beforeEach(() => {
fetchMock.mockClear();
fetchMock.resetBehavior();
fetchMock.post(tokenEndpoint, {
status: 200,
body: JSON.stringify(validBearerTokenResponse),
});
});
it("should make correct request to the token endpoint", async () => {
await completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig });
expect(fetchMock).toHaveBeenCalledWith(tokenEndpoint, {
method: Method.Post,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&redirect_uri=https%3A%2F%2Ftest.com&code=${code}`,
});
});
it("should return with valid bearer token", async () => {
const result = await completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
});
expect(result).toEqual(validBearerTokenResponse);
});
it("should return with valid bearer token where token_type is lowercase", async () => {
const tokenResponse = {
...validBearerTokenResponse,
token_type: "bearer",
};
fetchMock.post(
tokenEndpoint,
{
status: 200,
body: JSON.stringify(tokenResponse),
},
{ overwriteRoutes: true },
);
const result = await completeAuthorizationCodeGrant(code, {
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
});
// results in token that uses 'Bearer' token type
expect(result).toEqual(validBearerTokenResponse);
expect(result.token_type).toEqual("Bearer");
});
it("should throw with code exchange failed error when request fails", async () => {
fetchMock.post(
tokenEndpoint,
{
status: 500,
},
{ overwriteRoutes: true },
);
await expect(() =>
completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }),
).rejects.toThrow(new Error(OidcError.CodeExchangeFailed));
});
it("should throw invalid token error when token is invalid", async () => {
const invalidBearerTokenResponse = {
...validBearerTokenResponse,
access_token: null,
};
fetchMock.post(
tokenEndpoint,
{ status: 200, body: JSON.stringify(invalidBearerTokenResponse) },
{ overwriteRoutes: true },
);
await expect(() =>
completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }),
).rejects.toThrow(new Error(OidcError.InvalidBearerTokenResponse));
});
});
});
+84
View File
@@ -0,0 +1,84 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMockJest from "fetch-mock-jest";
import { OidcError } from "../../../src/oidc/error";
import { registerOidcClient } from "../../../src/oidc/register";
describe("registerOidcClient()", () => {
const issuer = "https://auth.com/";
const registrationEndpoint = "https://auth.com/register";
const clientName = "Element";
const baseUrl = "https://just.testing";
const dynamicClientId = "xyz789";
const delegatedAuthConfig = {
issuer,
registrationEndpoint,
authorizationEndpoint: issuer + "auth",
tokenEndpoint: issuer + "token",
};
beforeEach(() => {
fetchMockJest.mockClear();
fetchMockJest.resetBehavior();
});
it("should make correct request to register client", async () => {
fetchMockJest.post(registrationEndpoint, {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId);
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, {
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
client_name: clientName,
client_uri: baseUrl,
response_types: ["code"],
grant_types: ["authorization_code", "refresh_token"],
redirect_uris: [baseUrl],
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
application_type: "web",
}),
});
});
it("should throw when registration request fails", async () => {
fetchMockJest.post(registrationEndpoint, {
status: 500,
});
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
OidcError.DynamicRegistrationFailed,
);
});
it("should throw when registration response is invalid", async () => {
fetchMockJest.post(registrationEndpoint, {
status: 200,
// no clientId in response
body: "{}",
});
expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
OidcError.DynamicRegistrationInvalid,
);
});
});
+196
View File
@@ -0,0 +1,196 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_AUTHENTICATION } from "../../../src";
import { logger } from "../../../src/logger";
import { validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "../../../src/oidc/validate";
import { OidcError } from "../../../src/oidc/error";
describe("validateWellKnownAuthentication()", () => {
const baseWk = {
"m.homeserver": {
base_url: "https://hs.org",
},
};
it("should throw not supported error when wellKnown has no m.authentication section", () => {
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported);
});
it("should throw misconfigured error when authentication issuer is not a string", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: { url: "test.com" },
},
};
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
});
it("should throw misconfigured error when authentication account is not a string", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: { url: "test" },
},
};
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
});
it("should throw misconfigured error when authentication account is false", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: false,
},
};
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured);
});
it("should return valid config when wk uses stable m.authentication", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: "account.com",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
issuer: "test.com",
account: "account.com",
});
});
it("should return valid config when m.authentication account is missing", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
issuer: "test.com",
});
});
it("should remove unexpected properties", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
somethingElse: "test",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
issuer: "test.com",
});
});
it("should return valid config when wk uses unstable prefix for m.authentication", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.unstable!]: {
issuer: "test.com",
account: "account.com",
},
};
expect(validateWellKnownAuthentication(wk)).toEqual({
issuer: "test.com",
account: "account.com",
});
});
});
describe("validateOIDCIssuerWellKnown", () => {
const validWk: any = {
authorization_endpoint: "https://test.org/authorize",
token_endpoint: "https://authorize.org/token",
registration_endpoint: "https://authorize.org/regsiter",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
};
beforeEach(() => {
// stub to avoid console litter
jest.spyOn(logger, "error")
.mockClear()
.mockImplementation(() => {});
});
it("should throw OP support error when wellKnown is not an object", () => {
expect(() => {
validateOIDCIssuerWellKnown([]);
}).toThrow(OidcError.OpSupport);
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
});
it("should log all errors before throwing", () => {
expect(() => {
validateOIDCIssuerWellKnown({
...validWk,
authorization_endpoint: undefined,
response_types_supported: [],
});
}).toThrow(OidcError.OpSupport);
expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid");
expect(logger.error).toHaveBeenCalledWith(
"OIDC issuer configuration: response_types_supported is invalid. code is required.",
);
});
it("should return validated issuer config", () => {
expect(validateOIDCIssuerWellKnown(validWk)).toEqual({
authorizationEndpoint: validWk.authorization_endpoint,
tokenEndpoint: validWk.token_endpoint,
registrationEndpoint: validWk.registration_endpoint,
});
});
it("should return validated issuer config without registrationendpoint", () => {
const wk = { ...validWk };
delete wk.registration_endpoint;
expect(validateOIDCIssuerWellKnown(wk)).toEqual({
authorizationEndpoint: validWk.authorization_endpoint,
tokenEndpoint: validWk.token_endpoint,
registrationEndpoint: undefined,
});
});
type TestCase = [string, any];
it.each<TestCase>([
["authorization_endpoint", undefined],
["authorization_endpoint", { not: "a string" }],
["token_endpoint", undefined],
["token_endpoint", { not: "a string" }],
["registration_endpoint", { not: "a string" }],
["response_types_supported", undefined],
["response_types_supported", "not an array"],
["response_types_supported", ["doesnt include code"]],
["grant_types_supported", undefined],
["grant_types_supported", "not an array"],
["grant_types_supported", ["doesnt include authorization_code"]],
["code_challenge_methods_supported", undefined],
["code_challenge_methods_supported", "not an array"],
["code_challenge_methods_supported", ["doesnt include S256"]],
])("should throw OP support error when %s is %s", (key, value) => {
const wk = {
...validWk,
[key]: value,
};
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport);
});
});
-78
View File
@@ -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,
+133 -1
View File
@@ -14,9 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import * as utils from "../test-utils/test-utils";
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
import { EventType, RoomState } from "../../src";
import {
createClient,
EventType,
MatrixClient,
RoomState,
UNSTABLE_MSC2666_MUTUAL_ROOMS,
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
UNSTABLE_MSC2666_SHARED_ROOMS,
} from "../../src";
describe("RoomMember", function () {
const roomId = "!foo:bar";
@@ -481,3 +491,125 @@ describe("RoomMember", function () {
});
});
});
describe("MutualRooms", () => {
let client: MatrixClient;
const HS_URL = "https://example.com";
const TEST_USER_ID = "@alice:localhost";
const TEST_DEVICE_ID = "xzcvb";
const QUERIED_USER = "@user:example.com";
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = true;
client = createClient({
baseUrl: HS_URL,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
});
});
afterEach(async () => {
await client.stopClient();
fetchMock.mockReset();
});
function enableFeature(feature: string) {
const mapping: Record<string, boolean> = {};
mapping[feature] = true;
fetchMock.get(`${HS_URL}/_matrix/client/versions`, {
unstable_features: mapping,
versions: ["v1.1"],
});
}
it("supports the initial MSC version (shared rooms)", async () => {
enableFeature(UNSTABLE_MSC2666_SHARED_ROOMS);
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/shared_rooms/:user_id", (rawUrl) => {
const segments = rawUrl.split("/");
const lastSegment = decodeURIComponent(segments[segments.length - 1]);
expect(lastSegment).toEqual(QUERIED_USER);
return {
joined: ["!test:example.com"],
};
});
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
expect(rooms).toEqual(["!test:example.com"]);
});
it("supports the renaming MSC version (mutual rooms)", async () => {
enableFeature(UNSTABLE_MSC2666_MUTUAL_ROOMS);
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms/:user_id", (rawUrl) => {
const segments = rawUrl.split("/");
const lastSegment = decodeURIComponent(segments[segments.length - 1]);
expect(lastSegment).toEqual(QUERIED_USER);
return {
joined: ["!test2:example.com"],
};
});
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
expect(rooms).toEqual(["!test2:example.com"]);
});
describe("can work the latest MSC version (query mutual rooms)", () => {
beforeEach(() => {
enableFeature(UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS);
});
it("works with a simple response", async () => {
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms", (rawUrl) => {
const url = new URL(rawUrl);
expect(url.searchParams.get("user_id")).toEqual(QUERIED_USER);
return {
joined: ["!test3:example.com"],
};
});
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
expect(rooms).toEqual(["!test3:example.com"]);
});
it("works with a paginated response", async () => {
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms", (rawUrl) => {
const url = new URL(rawUrl);
expect(url.searchParams.get("user_id")).toEqual(QUERIED_USER);
const token = url.searchParams.get("batch_token");
if (token == "yahaha") {
return {
joined: ["!korok:example.com"],
};
} else {
return {
joined: ["!rock:example.com"],
next_batch_token: "yahaha",
};
}
});
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
expect(rooms).toEqual(["!rock:example.com", "!korok:example.com"]);
});
});
});
+286 -266
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);
});
});
});
+211 -14
View File
@@ -19,15 +19,26 @@ import { IDBFactory } from "fake-indexeddb";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js";
import { Mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import {
HttpApiEvent,
HttpApiEventHandlerMap,
IHttpOpts,
IToDeviceEvent,
MatrixClient,
MatrixHttpApi,
TypedEventEmitter,
} 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 { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
import { CryptoCallbacks, ImportRoomKeysOpts } from "../../../src/crypto-api";
import * as testData from "../../test-utils/test-data";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -40,16 +51,63 @@ const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe("RustCrypto", () => {
describe(".exportRoomKeys", () => {
describe(".importRoomKeys and .exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto();
});
beforeEach(
async () => {
rustCrypto = await makeTestRustCrypto();
},
/* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
10000,
);
it("should import and export keys", async () => {
const someRoomKeys = [
{
algorithm: "m.megolm.v1.aes-sha2",
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
session_id: "iGQG5GaP1/B3dSH6zCQDQqrNuotrtQjVC7w1OsUDwbg",
session_key:
"AQAAAADaCbP2gdOy8jrhikjploKgSBaFSJ5rvHcziaADbwNEzeCSrfuAUlXvCvxik8kU+MfCHIi5arN2M7UM5rGKdzkHnkReoIByFkeMdbjKWk5SFpVQexcM74eDhBGj+ICkQqOgApfnEbSswrmreB0+MhHHyLStwW5fy5f8A9QW1sbPuohkBuRmj9fwd3Uh+swkA0KqzbqLa7UI1Qu8NTrFA8G4",
sender_claimed_keys: {
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
},
forwarding_curve25519_key_chain: [],
},
{
algorithm: "m.megolm.v1.aes-sha2",
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
session_id: "P/Jy9Tog4CMtLseeS4Fe2AEXZov3k6cibcop/uyhr78",
session_key:
"AQAAAAATyAVm0c9c9DW9Od72MxvfSDYoysBw3C6yMJ3bYuTmssHN7yNGm59KCtKeFp2Y5qO7lvUmwOfSTvTASUb7HViE7Lt+Bvp5WiMTJ2Pv6m+N12ihyowV5lgtKFWI18Wxd0AugMTVQRwjBK6aMobf86NXWD2hiKm3N6kWbC0PXmqV7T/ycvU6IOAjLS7HnkuBXtgBF2aL95OnIm3KKf7soa+/",
sender_claimed_keys: {
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
},
forwarding_curve25519_key_chain: [],
},
];
let importTotal = 0;
const opt: ImportRoomKeysOpts = {
progressCallback: (stage) => {
importTotal = stage.total;
},
};
await rustCrypto.importRoomKeys(someRoomKeys, opt);
expect(importTotal).toBe(2);
it("should return a list", async () => {
const keys = await rustCrypto.exportRoomKeys();
expect(Array.isArray(keys)).toBeTruthy();
expect(keys.length).toBe(2);
const aSession = someRoomKeys[0];
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
expect(aSession).toStrictEqual(exportedKey);
});
});
@@ -93,19 +151,20 @@ 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", async () => {
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 () => {
@@ -166,6 +225,7 @@ describe("RustCrypto", () => {
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
});
@@ -288,6 +348,7 @@ describe("RustCrypto", () => {
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
);
});
@@ -311,6 +372,141 @@ describe("RustCrypto", () => {
expect(res).toBe(null);
});
});
describe("userHasCrossSigningKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto(undefined, testData.TEST_USER_ID);
});
afterEach(() => {
jest.useRealTimers();
});
it("returns false initially", async () => {
jest.useFakeTimers();
const prom = rustCrypto.userHasCrossSigningKeys();
// the getIdentity() request should wait for a /keys/query request to complete, but times out after 1500ms
await jest.advanceTimersByTimeAsync(2000);
await expect(prom).resolves.toBe(false);
});
it("returns false if there is no cross-signing identity", async () => {
// @ts-ignore private field
const olmMachine = rustCrypto.olmMachine;
const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests();
// pick out the KeysQueryRequest, and respond to it with the device keys but *no* cross-signing keys.
const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!;
await olmMachine.markRequestAsSent(
req.id!,
req.type,
JSON.stringify({
device_keys: {
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
},
}),
);
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(false);
});
it("returns true if OlmMachine has a cross-signing identity", async () => {
// @ts-ignore private field
const olmMachine = rustCrypto.olmMachine;
const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests();
// pick out the KeysQueryRequest, and respond to it with the cross-signing keys
const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!;
await olmMachine.markRequestAsSent(
req.id!,
req.type,
JSON.stringify({
device_keys: {
[testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA },
},
...testData.SIGNED_CROSS_SIGNING_KEYS_DATA,
}),
);
// ... and we should now have cross-signing keys.
await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(true);
});
});
describe("createRecoveryKeyFromPassphrase", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto();
});
it("should create a recovery key without password", async () => {
const recoveryKey = await rustCrypto.createRecoveryKeyFromPassphrase();
// Expected the encoded private key to have 59 chars
expect(recoveryKey.encodedPrivateKey?.length).toBe(59);
// Expect the private key to be an Uint8Array with a length of 32
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
expect(recoveryKey.privateKey.length).toBe(32);
// Expect keyInfo to be empty
expect(Object.keys(recoveryKey.keyInfo!).length).toBe(0);
});
it("should create a recovery key with password", async () => {
const recoveryKey = await rustCrypto.createRecoveryKeyFromPassphrase("my password");
// Expected the encoded private key to have 59 chars
expect(recoveryKey.encodedPrivateKey?.length).toBe(59);
// Expect the private key to be an Uint8Array with a length of 32
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
expect(recoveryKey.privateKey.length).toBe(32);
// Expect keyInfo.passphrase to be filled
expect(recoveryKey.keyInfo?.passphrase?.algorithm).toBe("m.pbkdf2");
expect(recoveryKey.keyInfo?.passphrase?.iterations).toBe(500000);
});
});
it("should wait for a keys/query before returning devices", async () => {
jest.useFakeTimers();
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
[testData.TEST_USER_ID]: {
[testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA,
},
},
});
const rustCrypto = await makeTestRustCrypto(mockHttpApi, testData.TEST_USER_ID);
// an attempt to fetch the device list should block
const devicesPromise = rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
// ... until a /sync completes, and we trigger the outgoingRequests.
rustCrypto.onSyncCompleted({});
const deviceMap = (await devicesPromise).get(testData.TEST_USER_ID)!;
expect(deviceMap.has(TEST_DEVICE_ID)).toBe(true);
expect(deviceMap.has(testData.TEST_DEVICE_ID)).toBe(true);
rustCrypto.stop();
});
describe("requestDeviceVerification", () => {
it("throws an error if the device is unknown", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(() => rustCrypto.requestDeviceVerification(TEST_USER, "unknown")).rejects.toThrow(
"Not a known device",
);
});
});
});
/** build a basic RustCrypto instance for testing
@@ -322,6 +518,7 @@ async function makeTestRustCrypto(
userId: string = TEST_USER,
deviceId: string = TEST_DEVICE_ID,
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto(http, userId, deviceId, secretStorage);
return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks);
}
@@ -0,0 +1,69 @@
/*
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 { secretStorageContainsCrossSigningKeys } from "../../../src/rust-crypto/secret-storage";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
describe("secret-storage", () => {
describe("secretStorageContainsCrossSigningKeys", () => {
it("should return false when the master cross-signing key is not stored in secret storage", async () => {
const secretStorage = {
isStored: jest.fn().mockReturnValue(false),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeFalsy();
});
it("should return false when there is no shared secret storage key between master, user signing and self signing keys", async () => {
const secretStorage = {
isStored: (type: string) => {
// Return different storage keys
if (type === "m.cross_signing.master") return { secretStorageKey: {} };
else return { secretStorageKey2: {} };
},
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeFalsy();
});
it("should return false when the secret storage key for the master key is only shared by the user-signing key", async () => {
const secretStorage = {
isStored: (type: string) => {
// Return different storage keys
if (type === "m.cross_signing.master" || type === "m.cross_signing.user_signing") {
return { secretStorageKey: {} };
} else {
return { secretStorageKey2: {} };
}
},
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeFalsy();
});
it("should return true when there is shared secret storage key between master, user signing and self signing keys", async () => {
const secretStorage = {
isStored: jest.fn().mockReturnValue({ secretStorageKey: {} }),
} as unknown as ServerSideSecretStorage;
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
expect(result).toBeTruthy();
});
});
});
+27
View File
@@ -254,4 +254,31 @@ describe("IndexedDBStore", () => {
});
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();
});
});
+17
View File
@@ -30,6 +30,8 @@ import {
sortEventsByLatestContentTimestamp,
safeSet,
MapWithDefault,
globToRegexp,
escapeRegExp,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
@@ -725,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\\\\\\)\\]"`);
});
});
});
+1 -8
View File
@@ -1054,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);
+100 -15
View File
@@ -18,25 +18,25 @@ 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";
import { GroupCall, GroupCallEvent, GroupCallState, GroupCallStatsReportEvent } from "../../../src/webrtc/groupCall";
import { IMyDevice, MatrixClient } from "../../../src/client";
import {
FAKE_CONF_ID,
FAKE_DEVICE_ID_1,
FAKE_DEVICE_ID_2,
FAKE_ROOM_ID,
FAKE_SESSION_ID_1,
FAKE_SESSION_ID_2,
FAKE_USER_ID_1,
FAKE_USER_ID_2,
FAKE_USER_ID_3,
installWebRTCMocks,
MockCallFeed,
MockCallMatrixClient,
MockMatrixCall,
MockMediaStream,
MockMediaStreamTrack,
MockRTCPeerConnection,
MockMatrixCall,
FAKE_ROOM_ID,
FAKE_USER_ID_1,
FAKE_CONF_ID,
FAKE_DEVICE_ID_2,
FAKE_SESSION_ID_2,
FAKE_USER_ID_2,
FAKE_DEVICE_ID_1,
FAKE_SESSION_ID_1,
FAKE_USER_ID_3,
} from "../../test-utils/webrtc";
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
import { sleep } from "../../../src/utils";
@@ -44,6 +44,9 @@ import { CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
import { CallFeed } from "../../../src/webrtc/callFeed";
import { CallEvent, CallState } from "../../../src/webrtc/call";
import { flushPromises } from "../../test-utils/flushPromises";
import { CallFeedReport } from "../../../src/webrtc/stats/statsReport";
import { CallFeedStatsReporter } from "../../../src/webrtc/stats/callFeedStatsReporter";
import { StatsReportEmitter } from "../../../src/webrtc/stats/statsReportEmitter";
const FAKE_STATE_EVENTS = [
{
@@ -186,10 +189,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);
});
@@ -1729,4 +1729,89 @@ describe("Group Call", function () {
expect(start).toHaveBeenCalled();
});
});
describe("as stats event listener and a CallFeedReport was triggered", () => {
let groupCall: GroupCall;
let reportEmitter: StatsReportEmitter;
const report: CallFeedReport = {} as CallFeedReport;
beforeEach(async () => {
CallFeedStatsReporter.expandCallFeedReport = jest.fn().mockReturnValue(report);
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);
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;
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
groupCall = await createAndEnterGroupCall(mockClient, room);
reportEmitter = groupCall.getGroupCallStats().reports;
});
it("should not extends with feed stats if no call exists", async () => {
const testPromise = new Promise<void>((done) => {
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith({}, [], "from-call-feed");
done();
});
});
const report: CallFeedReport = {} as CallFeedReport;
reportEmitter.emitCallFeedReport(report);
await testPromise;
});
it("and a CallFeedReport was triggered then it should extends with local feed", async () => {
const localCallFeed = {} as CallFeed;
groupCall.localCallFeed = localCallFeed;
const testPromise = new Promise<void>((done) => {
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
report,
[localCallFeed],
"from-local-feed",
);
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
report,
[],
"from-call-feed",
);
done();
});
});
const report: CallFeedReport = {} as CallFeedReport;
reportEmitter.emitCallFeedReport(report);
await testPromise;
});
it("and a CallFeedReport was triggered then it should extends with remote feed", async () => {
const localCallFeed = {} as CallFeed;
groupCall.localCallFeed = localCallFeed;
// @ts-ignore Suppress error because access to private property
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
report.callId = call.callId;
const feeds = call.getFeeds();
const testPromise = new Promise<void>((done) => {
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
report,
[localCallFeed],
"from-local-feed",
);
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
report,
feeds,
"from-call-feed",
);
done();
});
});
reportEmitter.emitCallFeedReport(report);
await testPromise;
});
});
});
@@ -0,0 +1,110 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
{
"callFeeds": [],
"callId": "CALL_ID",
"opponentMemberId": "USER_ID",
"transceiver": [
{
"currentDirection": "sendonly",
"direction": "sendrecv",
"mid": "0",
"receiver": {
"constrainDeviceId": "constrainDeviceId-receiver_audio_0",
"enabled": true,
"id": "receiver_audio_0",
"kind": "audio",
"label": "receiver",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-receiver_audio_0",
},
"sender": {
"constrainDeviceId": "constrainDeviceId-sender_audio_0",
"enabled": true,
"id": "sender_audio_0",
"kind": "audio",
"label": "sender",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-sender_audio_0",
},
},
{
"currentDirection": "sendrecv",
"direction": "recvonly",
"mid": "1",
"receiver": {
"constrainDeviceId": "constrainDeviceId-receiver_video_1",
"enabled": true,
"id": "receiver_video_1",
"kind": "video",
"label": "receiver",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-receiver_video_1",
},
"sender": {
"constrainDeviceId": "constrainDeviceId-sender_video_1",
"enabled": true,
"id": "sender_video_1",
"kind": "video",
"label": "sender",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-sender_video_1",
},
},
{
"currentDirection": "recvonly",
"direction": "recvonly",
"mid": "2",
"receiver": {
"constrainDeviceId": "constrainDeviceId-receiver_video_2",
"enabled": true,
"id": "receiver_video_2",
"kind": "video",
"label": "receiver",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-receiver_video_2",
},
"sender": null,
},
],
}
`;
exports[`CallFeedStatsReporter should extends CallFeedReport with call feeds 1`] = `
[
{
"audio": {
"constrainDeviceId": "constrainDeviceId-video-1",
"enabled": true,
"id": "video-1",
"kind": "video",
"label": "--",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-video-1",
},
"isAudioMuted": true,
"isVideoMuted": false,
"prefix": "unknown",
"purpose": undefined,
"stream": "stream-1",
"type": "local",
"video": {
"constrainDeviceId": "constrainDeviceId-audio-1",
"enabled": true,
"id": "audio-1",
"kind": "audio",
"label": "--",
"muted": false,
"readyState": "live",
"settingDeviceId": "settingDeviceId-audio-1",
},
},
]
`;
@@ -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 { CallFeedStatsReporter } from "../../../../src/webrtc/stats/callFeedStatsReporter";
import { CallFeedReport } from "../../../../src/webrtc/stats/statsReport";
import { CallFeed } from "../../../../src/webrtc/callFeed";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("CallFeedStatsReporter", () => {
let rtcSpy: RTCPeerConnection;
beforeEach(() => {
rtcSpy = {} as RTCPeerConnection;
rtcSpy.getTransceivers = jest.fn().mockReturnValue(buildTransceiverMocks());
});
describe("should", () => {
it("builds CallFeedReport", async () => {
expect(CallFeedStatsReporter.buildCallFeedReport(CALL_ID, USER_ID, rtcSpy)).toMatchSnapshot();
});
it("extends CallFeedReport with call feeds", async () => {
const feed = buildCallFeedMock("1");
const callFeedList: CallFeed[] = [feed];
const report = {
callId: "callId",
opponentMemberId: "opponentMemberId",
transceiver: [],
callFeeds: [],
} as CallFeedReport;
expect(CallFeedStatsReporter.expandCallFeedReport(report, callFeedList).callFeeds).toMatchSnapshot();
});
});
const buildTransceiverMocks = (): RTCRtpTransceiver[] => {
const trans1 = {
mid: "0",
direction: "sendrecv",
currentDirection: "sendonly",
sender: buildSenderMock("sender_audio_0", "audio"),
receiver: buildReceiverMock("receiver_audio_0", "audio"),
} as RTCRtpTransceiver;
const trans2 = {
mid: "1",
direction: "recvonly",
currentDirection: "sendrecv",
sender: buildSenderMock("sender_video_1", "video"),
receiver: buildReceiverMock("receiver_video_1", "video"),
} as RTCRtpTransceiver;
const trans3 = {
mid: "2",
direction: "recvonly",
currentDirection: "recvonly",
sender: { track: null } as RTCRtpSender,
receiver: buildReceiverMock("receiver_video_2", "video"),
} as RTCRtpTransceiver;
return [trans1, trans2, trans3];
};
const buildSenderMock = (id: string, kind: "audio" | "video"): RTCRtpSender => {
const track = buildTrackMock(id, kind);
return {
track,
} as RTCRtpSender;
};
const buildReceiverMock = (id: string, kind: "audio" | "video"): RTCRtpReceiver => {
const track = buildTrackMock(id, kind);
return {
track,
} as RTCRtpReceiver;
};
const buildTrackMock = (id: string, kind: "audio" | "video"): MediaStreamTrack => {
return {
id,
kind,
enabled: true,
label: "--",
muted: false,
readyState: "live",
getSettings: () => ({ deviceId: `settingDeviceId-${id}` }),
getConstraints: () => ({ deviceId: `constrainDeviceId-${id}` }),
} as MediaStreamTrack;
};
const buildCallFeedMock = (id: string, isLocal = true): CallFeed => {
const stream = {
id: `stream-${id}`,
getAudioTracks(): MediaStreamTrack[] {
return [buildTrackMock(`video-${id}`, "video")];
},
getVideoTracks(): MediaStreamTrack[] {
return [buildTrackMock(`audio-${id}`, "audio")];
},
} as MediaStream;
return {
stream,
isLocal: () => isLocal,
isVideoMuted: () => false,
isAudioMuted: () => true,
} as CallFeed;
};
});
@@ -0,0 +1,256 @@
/*
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";
import { currentChromeReport, prevChromeReport } from "../../../test-utils/webrtcReports";
import { MockRTCPeerConnection } from "../../../test-utils/webrtc";
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();
rtcSpy.getTransceivers = jest.fn().mockReturnValue([]);
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("should not only produce a call summary stat but also", () => {
const wantedSummaryReport = {
isFirstCollection: false,
audioTrackSummary: {
concealedAudio: 0,
count: 0,
maxJitter: 0,
maxPacketLoss: 0,
muted: 0,
totalAudio: 0,
},
receivedAudioMedia: 0,
receivedMedia: 0,
receivedVideoMedia: 0,
videoTrackSummary: {
concealedAudio: 0,
count: 0,
maxJitter: 0,
maxPacketLoss: 0,
muted: 0,
totalAudio: 0,
},
};
beforeEach(() => {
rtcSpy = new MockRTCPeerConnection() as unknown as RTCPeerConnection;
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
const getStats = jest.spyOn(rtcSpy, "getStats");
const previous = prevChromeReport as unknown as RTCStatsReport;
previous.get = (id: string) => {
return prevChromeReport.find((data) => data.id === id);
};
// @ts-ignore
collector.previousStatsReport = previous;
const current = currentChromeReport as unknown as RTCStatsReport;
current.get = (id: string) => {
return currentChromeReport.find((data) => data.id === id);
};
// @ts-ignore
getStats.mockResolvedValue(current);
});
it("emit byteSentStatsReport", async () => {
const emitByteSendReport = jest.spyOn(emitter, "emitByteSendReport");
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual(wantedSummaryReport);
expect(emitByteSendReport).toHaveBeenCalled();
});
it("emit emitConnectionStatsReport", async () => {
const emitConnectionStatsReport = jest.spyOn(emitter, "emitConnectionStatsReport");
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual(wantedSummaryReport);
expect(emitConnectionStatsReport).toHaveBeenCalled();
});
it("emit callFeedStatsReport", async () => {
const emitCallFeedReport = jest.spyOn(emitter, "emitCallFeedReport");
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual(wantedSummaryReport);
expect(emitCallFeedReport).toHaveBeenCalled();
});
});
});
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");
});
});
});
@@ -16,7 +16,7 @@ limitations under the License.
import { TrackID } from "../../../../src/webrtc/stats/statsReport";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder";
import { ConnectionStatsReportBuilder } from "../../../../src/webrtc/stats/connectionStatsReportBuilder";
describe("StatsReportBuilder", () => {
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
@@ -39,7 +39,7 @@ describe("StatsReportBuilder", () => {
describe("should build stats", () => {
it("by media track stats.", async () => {
expect(StatsReportBuilder.build(stats)).toEqual({
expect(ConnectionStatsReportBuilder.build(stats)).toEqual({
bitrate: {
audio: {
download: 4000,
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter";
import { ConnectionStatsBuilder } from "../../../../src/webrtc/stats/connectionStatsBuilder";
describe("ConnectionStatsReporter", () => {
describe("should on bandwidth stats", () => {
@@ -22,11 +22,11 @@ describe("ConnectionStatsReporter", () => {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
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(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
@@ -36,11 +36,11 @@ describe("ConnectionStatsReporter", () => {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
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(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
});
+19 -4
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
import { SummaryStats } from "../../../../src/webrtc/stats/summaryStats";
import { CallStatsReportSummary } from "../../../../src/webrtc/stats/callStatsReportSummary";
const GROUP_CALL_ID = "GROUP_ID";
const LOCAL_USER_ID = "LOCAL_USER_ID";
@@ -92,12 +92,27 @@ describe("GroupCallStats", () => {
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 },
videoTrackSummary: { count: 0, muted: 0 },
} as SummaryStats;
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);
@@ -16,6 +16,7 @@ limitations under the License.
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import {
ByteSentStatsReport,
CallFeedReport,
ConnectionStatsReport,
StatsReport,
SummaryStatsReport,
@@ -62,4 +63,16 @@ describe("StatsReportEmitter", () => {
emitter.emitSummaryStatsReport(report);
});
});
it("should emit and receive CallFeedReports", async () => {
const report = {} as CallFeedReport;
return new Promise((resolve, _) => {
emitter.on(StatsReport.CALL_FEED_REPORT, (r) => {
expect(r).toBe(report);
resolve(null);
return;
});
emitter.emitCallFeedReport(report);
});
});
});
@@ -1,112 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("StatsReportGatherer", () => {
let collector: StatsReportGatherer;
let rtcSpy: RTCPeerConnection;
let emitter: StatsReportEmitter;
beforeEach(() => {
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
rtcSpy.addEventListener = jest.fn();
emitter = new StatsReportEmitter();
collector = new StatsReportGatherer(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({
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 an RTCStatsReport not a promise the collector 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({
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();
});
});
});
@@ -13,16 +13,17 @@ 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 { SummaryStatsReporter } from "../../../../src/webrtc/stats/summaryStatsReporter";
import { SummaryStatsReportGatherer } from "../../../../src/webrtc/stats/summaryStatsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import { groupCallParticipantsFourOtherDevices } from "../../../test-utils/webrtc";
describe("SummaryStatsReporter", () => {
let reporter: SummaryStatsReporter;
describe("SummaryStatsReportGatherer", () => {
let reporter: SummaryStatsReportGatherer;
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
emitter.emitSummaryStatsReport = jest.fn();
reporter = new SummaryStatsReporter(emitter);
reporter = new SummaryStatsReportGatherer(emitter);
});
describe("build Summary Stats Report", () => {
@@ -30,10 +31,38 @@ describe("SummaryStatsReporter", () => {
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,
@@ -55,6 +84,7 @@ describe("SummaryStatsReporter", () => {
},
},
{
isFirstCollection: false,
receivedMedia: 13,
receivedAudioMedia: 0,
receivedVideoMedia: 13,
@@ -76,6 +106,7 @@ describe("SummaryStatsReporter", () => {
},
},
{
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
@@ -97,6 +128,7 @@ describe("SummaryStatsReporter", () => {
},
},
{
isFirstCollection: false,
receivedMedia: 15,
receivedAudioMedia: 6,
receivedVideoMedia: 9,
@@ -133,6 +165,7 @@ describe("SummaryStatsReporter", () => {
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,
@@ -169,6 +202,7 @@ describe("SummaryStatsReporter", () => {
it("as received no video Media, because only on video was muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
@@ -205,6 +239,7 @@ describe("SummaryStatsReporter", () => {
it("as received no audio Media, although audio not received and audio muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 100,
receivedAudioMedia: 0,
receivedVideoMedia: 100,
@@ -241,6 +276,7 @@ describe("SummaryStatsReporter", () => {
it("should find max jitter and max packet loss", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
@@ -262,6 +298,7 @@ describe("SummaryStatsReporter", () => {
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
@@ -283,6 +320,7 @@ describe("SummaryStatsReporter", () => {
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
@@ -304,6 +342,7 @@ describe("SummaryStatsReporter", () => {
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
@@ -340,6 +379,7 @@ describe("SummaryStatsReporter", () => {
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,
@@ -376,6 +416,7 @@ describe("SummaryStatsReporter", () => {
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,
@@ -412,6 +453,7 @@ describe("SummaryStatsReporter", () => {
it("as received no media at all, as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
@@ -444,5 +486,218 @@ describe("SummaryStatsReporter", () => {
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: 4,
percentageConcealedAudio: 0,
});
});
it("should report missing peer connections", async () => {
const summary = [
{
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: 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: 2,
percentageConcealedAudio: 0,
});
});
});
describe("extend Summary Stats Report", () => {
it("should extend the report with the appropriate data based on a user map", async () => {
const summary = {
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 2,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
};
SummaryStatsReportGatherer.extendSummaryReport(summary, groupCallParticipantsFourOtherDevices);
expect(summary).toStrictEqual({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 2,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
opponentUsersInCall: 1,
opponentDevicesInCall: 4,
diffDevicesToPeerConnections: 0,
ratioPeerConnectionToDevices: 1,
});
});
it("should extend the report data based on a user map", async () => {
const summary = {
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 2,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
};
SummaryStatsReportGatherer.extendSummaryReport(summary, new Map());
expect(summary).toStrictEqual({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 2,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
opponentUsersInCall: 0,
opponentDevicesInCall: 0,
diffDevicesToPeerConnections: -4,
ratioPeerConnectionToDevices: 0,
});
});
});
});
@@ -13,20 +13,20 @@ 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 { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter";
import { TrackStatsBuilder } from "../../../../src/webrtc/stats/trackStatsBuilder";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
describe("TrackStatsReporter", () => {
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");
TrackStatsReporter.buildFramerateResolution(trackStats, {});
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");
TrackStatsReporter.buildFramerateResolution(trackStats, {
TrackStatsBuilder.buildFramerateResolution(trackStats, {
framesPerSecond: 22.2,
frameHeight: 180,
frameWidth: 360,
@@ -39,7 +39,7 @@ describe("TrackStatsReporter", () => {
describe("should on simulcast", () => {
it("creating simulcast framerate.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.calculateSimulcastFramerate(
TrackStatsBuilder.calculateSimulcastFramerate(
trackStats,
{
framesSent: 100,
@@ -58,7 +58,7 @@ describe("TrackStatsReporter", () => {
describe("should on bytes received stats", () => {
it("creating build bitrate received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildBitrateReceived(
TrackStatsBuilder.buildBitrateReceived(
trackStats,
{
bytesReceived: 2001000,
@@ -73,7 +73,7 @@ describe("TrackStatsReporter", () => {
describe("should on bytes send stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildBitrateSend(
TrackStatsBuilder.buildBitrateSend(
trackStats,
{
bytesSent: 2001000,
@@ -90,7 +90,7 @@ describe("TrackStatsReporter", () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const remote = {} as RTCStatsReport;
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" });
TrackStatsBuilder.buildCodec(remote, trackStats, { codecId: "codecID" });
expect(trackStats.getCodec()).toEqual("v8");
});
});
@@ -98,7 +98,7 @@ describe("TrackStatsReporter", () => {
describe("should on package lost stats", () => {
it("creating build package lost on send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildPacketsLost(
TrackStatsBuilder.buildPacketsLost(
trackStats,
{
type: "outbound-rtp",
@@ -114,7 +114,7 @@ describe("TrackStatsReporter", () => {
});
it("creating build package lost on received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildPacketsLost(
TrackStatsBuilder.buildPacketsLost(
trackStats,
{
type: "inbound-rtp",
@@ -133,7 +133,7 @@ describe("TrackStatsReporter", () => {
describe("should set state of a TrackStats", () => {
it("to not alive if Transceiver undefined", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.setTrackStatsState(trackStats, undefined);
TrackStatsBuilder.setTrackStatsState(trackStats, undefined);
expect(trackStats.alive).toBeFalsy();
});
@@ -145,7 +145,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
@@ -162,7 +162,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
@@ -179,7 +179,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
@@ -195,7 +195,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
@@ -211,7 +211,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
expect(trackStats.muted).toBeTruthy();
});
@@ -219,7 +219,7 @@ describe("TrackStatsReporter", () => {
describe("should build Track Summary", () => {
it("and returns empty summary if stats list empty", async () => {
const summary = TrackStatsReporter.buildTrackSummary([]);
const summary = TrackStatsBuilder.buildTrackSummary([]);
expect(summary).toEqual({
audioTrackSummary: {
count: 0,
@@ -242,7 +242,7 @@ describe("TrackStatsReporter", () => {
it("and returns summary if stats list not empty and ignore local summery", async () => {
const trackStatsList = buildMockTrackStatsList();
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
@@ -267,7 +267,7 @@ describe("TrackStatsReporter", () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[5].muted = true;
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
@@ -292,7 +292,7 @@ describe("TrackStatsReporter", () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[1].alive = false;
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
@@ -330,7 +330,7 @@ describe("TrackStatsReporter", () => {
trackStatsList[2].setAudioConcealment(220, 2000);
trackStatsList[5].setAudioConcealment(180, 2000);
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
@@ -355,25 +355,25 @@ describe("TrackStatsReporter", () => {
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");
TrackStatsReporter.buildJitter(trackStats, { jitter: 0.01 });
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");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
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");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp" });
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");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
expect(trackStats.getJitter()).toEqual(500);
});
});
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter";
import { TransportStatsBuilder } from "../../../../src/webrtc/stats/transportStatsBuilder";
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
describe("TransportStatsReporter", () => {
@@ -35,7 +35,7 @@ describe("TransportStatsReporter", () => {
it("build new transport stats if all properties there", () => {
const { report, stats } = mockStatsReport(isFocus, 0);
const conferenceStatsTransport: TransportStats[] = [];
const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus);
const transportStats = TransportStatsBuilder.buildReport(report, stats, conferenceStatsTransport, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
@@ -54,8 +54,8 @@ describe("TransportStatsReporter", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 1);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
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}`,
@@ -84,8 +84,8 @@ describe("TransportStatsReporter", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 0);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
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}`,
@@ -13,16 +13,16 @@ 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 { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter";
import { ValueFormatter } from "../../../../src/webrtc/stats/valueFormatter";
describe("StatsValueFormatter", () => {
describe("ValueFormatter", () => {
describe("on get non negative values", () => {
it("formatter shod return number", async () => {
expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2);
expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
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 -2
View File
@@ -168,11 +168,11 @@ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
/**
* Name of the "with_relations" request property for relation based redactions.
* Name of the request property for relation based redactions.
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
*/
export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new UnstableValue(
"with_relations",
"with_rel_types",
"org.matrix.msc3912.with_relations",
);
@@ -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;
+2 -3
View File
@@ -48,17 +48,16 @@ export interface IJoinRoomOpts {
export interface IRedactOpts {
reason?: string;
/**
* Whether events related to the redacted event should be redacted.
*
* If specified, then any events which relate to the event being redacted with
* any of the relationship types listed will also be redacted.
* Provide a "*" list item to tell the server to redact relations of any type.
*
* <b>Raises an Error if the server does not support it.</b>
* Check for server-side support before using this param with
* <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>.
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
*/
with_relations?: Array<RelationType | string>;
with_rel_types?: Array<RelationType | "*">;
}
export interface ISendEventResponse {
+70 -8
View File
@@ -15,9 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IClientWellKnown, IWellKnownConfig } from "./client";
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
import { logger } from "./logger";
import { MatrixError, Method, timeoutSignal } from "./http-api";
import { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate";
import { OidcError } from "./oidc/error";
// Dev note: Auto discovery is part of the spec.
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -42,14 +44,18 @@ enum AutoDiscoveryError {
InvalidJson = "Invalid JSON",
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
interface AutoDiscoveryState {
state: AutoDiscoveryAction;
error?: IWellKnownConfig["error"] | null;
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
"m.authentication"?: DelegatedAuthConfig | AutoDiscoveryState;
}
/**
@@ -142,7 +148,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;
@@ -170,8 +176,8 @@ 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"]) {
const hsVersions = await this.fetchWellKnownObject<IServerVersions>(`${hsUrl}/_matrix/client/versions`);
if (!hsVersions?.raw?.["versions"]) {
logger.error("Invalid /versions response");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
@@ -256,10 +262,63 @@ export class AutoDiscovery {
}
});
const authConfig = await this.validateDiscoveryAuthenticationConfig(wellknown);
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
}
/**
* Validate delegated auth configuration
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, DelegatedAuthConfig will be returned with endpoints used for delegated auth
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async validateDiscoveryAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
)}/.well-known/openid-configuration`;
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(issuerOpenIdConfigUrl);
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Failed to fetch issuer openid configuration");
throw new Error(OidcError.General);
}
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
const delegatedAuthConfig: DelegatedAuthConfig = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...homeserverAuthenticationConfig,
...validatedIssuerConfig,
};
return delegatedAuthConfig;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Attempts to automatically discover client configuration information
* prior to logging in. Such information includes the homeserver URL
@@ -308,7 +367,8 @@ export class AutoDiscovery {
// Step 1: Actually request the .well-known JSON file and make sure it
// at least has a homeserver definition.
const wellknown = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
const domainWithProtocol = domain.includes("://") ? domain : `https://${domain}`;
const wellknown = await this.fetchWellKnownObject(`${domainWithProtocol}/.well-known/matrix/client`);
if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("No response or error when parsing .well-known");
if (wellknown.reason) logger.error(wellknown.reason);
@@ -345,7 +405,7 @@ export class AutoDiscovery {
const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
if (!response) return {};
return response.raw || {};
return response.raw ?? {};
}
/**
@@ -412,7 +472,9 @@ export class AutoDiscovery {
* @returns Promise which resolves to the returned state.
* @internal
*/
private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
private static async fetchWellKnownObject<T = IWellKnownConfig>(
url: string,
): Promise<IWellKnownConfig<Partial<T>>> {
let response: Response;
try {
+180 -100
View File
@@ -36,7 +36,11 @@ import { StubStore } from "./store/stub";
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
import {
GroupCallEventHandler,
GroupCallEventHandlerEvent,
GroupCallEventHandlerEventHandlerMap,
} from "./webrtc/groupCallEventHandler";
import * as utils from "./utils";
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
import { Direction, EventTimeline } from "./models/event-timeline";
@@ -99,13 +103,7 @@ import { MatrixScheduler } from "./scheduler";
import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
import { IAuthData, IAuthDict } from "./interactive-auth";
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
import {
CrossSigningKey,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
IRecoveryKey,
} from "./crypto/api";
import { CrossSigningKey, ICreateSecretStorageOpts, IEncryptedEventInfo, IRecoveryKey } from "./crypto/api";
import { EventTimelineSet } from "./models/event-timeline-set";
import { VerificationRequest } from "./crypto/verification/request/VerificationRequest";
import { VerificationBase as Verification } from "./crypto/verification/Base";
@@ -180,7 +178,6 @@ import { IThreepid } from "./@types/threepids";
import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
import { MediaHandler } from "./webrtc/mediaHandler";
import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
import { TypedEventEmitter } from "./models/typed-event-emitter";
import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
@@ -204,7 +201,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
import { CryptoBackend } from "./common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
import { BootstrapCrossSigningOpts, CryptoApi } from "./crypto-api";
import { BootstrapCrossSigningOpts, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
import { DeviceInfoMap } from "./crypto/DeviceList";
import {
AddSecretStorageKeyOpts,
@@ -339,6 +336,11 @@ export interface ICreateClientOpts {
*/
pickleKey?: string;
/**
* Verification methods we should offer to the other side when performing an interactive verification.
* If unset, we will offer all known methods. Currently these are: showing a QR code, scanning a QR code, and SAS
* (aka "emojis").
*/
verificationMethods?: Array<VerificationMethod>;
/**
@@ -370,6 +372,9 @@ export interface ICreateClientOpts {
*/
useE2eForGroupCall?: boolean;
/**
* Crypto callbacks provided by the application
*/
cryptoCallbacks?: ICryptoCallbacks;
/**
@@ -466,11 +471,6 @@ export interface IStartClientOpts {
* @experimental
*/
slidingSync?: SlidingSync;
/**
* @experimental
*/
intentionalMentions?: boolean;
}
export interface IStoredClientOpts extends IStartClientOpts {}
@@ -497,6 +497,10 @@ export interface IMSC3882GetLoginTokenCapability extends ICapability {}
export const UNSTABLE_MSC3882_CAPABILITY = new UnstableValue("m.get_login_token", "org.matrix.msc3882.get_login_token");
export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
@@ -590,8 +594,8 @@ export interface IClientWellKnown {
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
}
export interface IWellKnownConfig {
raw?: IClientWellKnown;
export interface IWellKnownConfig<T = IClientWellKnown> {
raw?: T;
action?: AutoDiscoveryAction;
reason?: string;
error?: Error | string;
@@ -867,6 +871,7 @@ export interface TimestampToEventResponse {
interface IWhoamiResponse {
user_id: string;
device_id?: string;
is_guest?: boolean;
}
/* eslint-enable camelcase */
@@ -2227,7 +2232,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
const rustCrypto = await RustCrypto.initRustCrypto(
this.http,
userId,
deviceId,
this.secretStorage,
this.cryptoCallbacks,
);
rustCrypto.supportedVerificationMethods = this.verificationMethods;
this.cryptoBackend = rustCrypto;
// attach the event listeners needed by RustCrypto
@@ -2435,12 +2448,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
* @deprecated Prefer {@link CryptoApi.findVerificationRequestDMInProgress}.
*/
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
} else if (!this.crypto) {
// Hack for element-R to avoid breaking the cypress tests. We can get rid of this once the react-sdk is
// updated to use CryptoApi.findVerificationRequestDMInProgress.
return undefined;
}
return this.cryptoBackend.findVerificationRequestDMInProgress(roomId);
return this.crypto.findVerificationRequestDMInProgress(roomId);
}
/**
@@ -2449,6 +2467,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param userId - the ID of the user to query
*
* @returns the VerificationRequests that are in progress
* @deprecated Prefer {@link CryptoApi.getVerificationRequestsToDeviceInProgress}.
*/
public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
if (!this.crypto) {
@@ -2466,6 +2485,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @returns resolves to a VerificationRequest
* when the request has been sent to the other party.
*
* @deprecated Prefer {@link CryptoApi#requestOwnUserVerification} or {@link CryptoApi#requestDeviceVerification}.
*/
public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
if (!this.crypto) {
@@ -2577,7 +2598,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* "master", "self_signing", or "user_signing". Defaults to "master".
*
* @returns the key ID
* @deprecated prefer {@link CryptoApi#getCrossSigningKeyId}
* @deprecated prefer {@link Crypto.CryptoApi#getCrossSigningKeyId}
*/
public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
if (!this.crypto) {
@@ -2624,7 +2645,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param userId - The ID of the user whose devices is to be checked.
* @param deviceId - The ID of the device to check
*
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
if (!this.crypto) {
@@ -2870,6 +2891,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* - migrates Secure Secret Storage to use the latest algorithm, if an outdated
* algorithm is found
*
* @deprecated Use {@link CryptoApi#bootstrapSecretStorage}.
*/
public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
if (!this.crypto) {
@@ -3193,14 +3215,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Import a list of room keys previously exported by exportRoomKeys
*
* @param keys - a list of session export objects
* @param opts - options object
*
* @returns a promise which resolves when the keys have been imported
*
* @deprecated Prefer {@link CryptoApi.importRoomKeys | `CryptoApi.importRoomKeys`}:
* ```javascript
* await client.getCrypto()?.importRoomKeys([..]);
* ```
*/
public importRoomKeys(keys: IMegolmSessionData[], opts?: IImportRoomKeysOpts): Promise<void> {
if (!this.crypto) {
public importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.importRoomKeys(keys, opts);
return this.cryptoBackend.importRoomKeys(keys, opts);
}
/**
@@ -3808,7 +3836,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
algorithm.free();
}
await this.importRoomKeys(keys, {
await this.getCrypto()?.importRoomKeys(keys, {
progressCallback,
untrusted,
source: "backup",
@@ -4087,27 +4115,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
queryString["server_name"] = opts.viaServers;
}
try {
const data: IJoinRequestBody = {};
const signedInviteObj = await signPromise;
if (signedInviteObj) {
data.third_party_signed = signedInviteObj;
}
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const roomId = res.room_id;
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const room = syncApi.createRoom(roomId);
if (opts.syncRoom) {
// v2 will do this for us
// return syncApi.syncRoom(room);
}
return room;
} catch (e) {
throw e; // rethrow for reject
const data: IJoinRequestBody = {};
const signedInviteObj = await signPromise;
if (signedInviteObj) {
data.third_party_signed = signedInviteObj;
}
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const roomId = res.room_id;
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const syncRoom = syncApi.createRoom(roomId);
if (opts.syncRoom) {
// v2 will do this for us
// return syncApi.syncRoom(room);
}
return syncRoom;
}
/**
@@ -4595,10 +4619,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @param txnId - transaction id. One will be made up if not supplied.
* @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
* @param opts - Redact options
* @returns Promise which resolves: TODO
* @returns Rejects: with an error response.
* @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
* @throws Error if called with `with_rel_types` (MSC3912) but the server does not support it.
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
*/
public redactEvent(
@@ -4628,34 +4652,30 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
threadId = null;
}
const reason = opts?.reason;
const content: IContent = { reason };
if (
opts?.with_relations &&
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
) {
throw new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
);
if (opts?.with_rel_types !== undefined) {
if (this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported) {
throw new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
);
}
const withRelTypesPropName =
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!;
content[withRelTypesPropName] = opts.with_rel_types;
}
const withRelations = opts?.with_relations
? {
[this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
}
: {};
return this.sendCompleteEvent(
roomId,
threadId,
{
type: EventType.RoomRedaction,
content: {
...withRelations,
reason,
},
content,
redacts: eventId,
},
txnId as string,
@@ -5005,7 +5025,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
rpEvent?: MatrixEvent,
): Promise<{}> {
const room = this.getRoom(roomId);
if (room && room.hasPendingEvent(rmEventId)) {
if (room?.hasPendingEvent(rmEventId)) {
throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
}
@@ -5058,9 +5078,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const key = ts + "_" + url;
// If there's already a request in flight (or we've handled it), return that instead.
const cachedPreview = this.urlPreviewCache[key];
if (cachedPreview) {
return cachedPreview;
if (key in this.urlPreviewCache) {
return this.urlPreviewCache[key];
}
const resp = this.http.authedRequest<IPreviewUrlResponse>(
@@ -5575,11 +5594,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room.currentState.setUnknownStateEvents(stateEvents);
}
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
const [timelineEvents, threadedEvents, unknownRelations] =
room.partitionThreadedEvents(matrixEvents);
this.processAggregatedTimelineEvents(room, timelineEvents);
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
this.processThreadEvents(room, threadedEvents, true);
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
room.oldState.paginationToken = res.end ?? null;
if (res.chunk.length === 0) {
@@ -5688,11 +5709,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
}
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
// The target event is not in a thread but process the contextual events, so we can show any threads around it.
this.processThreadEvents(timelineSet.room, threadedEvents, true);
this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
unknownRelations.forEach((event) => timelineSet.relations.aggregateChildEvent(event));
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@@ -6232,7 +6254,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
const [timelineEvents, , unknownRelations] = room.partitionThreadedEvents(matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
this.processAggregatedTimelineEvents(room, timelineEvents);
this.processThreadRoots(
@@ -6240,6 +6262,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
false,
);
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
const atEnd = res.end === undefined || res.end === res.start;
@@ -7146,29 +7169,75 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Gets a set of room IDs in common with another user
* Gets a set of room IDs in common with another user.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC2666](https://github.com/matrix-org/matrix-spec-proposals/pull/2666) for more details.
*
* @param userId - The userId to check.
* @returns Promise which resolves to a set of rooms
* @returns Promise which resolves to an array of rooms
* @returns Rejects: with an error response.
*/
// TODO: on spec release, rename this to getMutualRooms
// eslint-disable-next-line
public async _unstable_getSharedRooms(userId: string): Promise<string[]> {
const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666");
const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms");
// Initial variant of the MSC
const sharedRoomsSupport = await this.doesServerSupportUnstableFeature(UNSTABLE_MSC2666_SHARED_ROOMS);
if (!sharedRoomsSupport && !mutualRoomsSupport) {
throw Error("Server does not support mutual_rooms API");
}
// Newer variant that renamed shared rooms to mutual rooms
const mutualRoomsSupport = await this.doesServerSupportUnstableFeature(UNSTABLE_MSC2666_MUTUAL_ROOMS);
const path = utils.encodeUri(
`/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`,
{ $userId: userId },
// Latest variant that changed from path elements to query elements
const queryMutualRoomsSupport = await this.doesServerSupportUnstableFeature(
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
);
const res = await this.http.authedRequest<{ joined: string[] }>(Method.Get, path, undefined, undefined, {
prefix: ClientPrefix.Unstable,
});
return res.joined;
if (!sharedRoomsSupport && !mutualRoomsSupport && !queryMutualRoomsSupport) {
throw Error("Server does not support the Mutual Rooms API");
}
let path;
let query;
// Cascading unstable support switching.
if (queryMutualRoomsSupport) {
path = "/uk.half-shot.msc2666/user/mutual_rooms";
query = { user_id: userId };
} else {
path = utils.encodeUri(
`/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`,
{ $userId: userId },
);
query = {};
}
// Accumulated rooms
const rooms: string[] = [];
let token = null;
do {
const tokenQuery: Record<string, string> = {};
if (token != null && queryMutualRoomsSupport) {
tokenQuery["batch_token"] = token;
}
const res = await this.http.authedRequest<{
joined: string[];
next_batch_token?: string;
}>(Method.Get, path, { ...query, ...tokenQuery }, undefined, {
prefix: ClientPrefix.Unstable,
});
rooms.push(...res.joined);
if (res.next_batch_token !== undefined) {
token = res.next_batch_token;
} else {
token = null;
}
} while (token != null);
return rooms;
}
/**
@@ -7687,16 +7756,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Rejects with an error response.
*/
public refreshToken(refreshToken: string): Promise<IRefreshTokenResponse> {
return this.http.authedRequest(
Method.Post,
"/refresh",
undefined,
{ refresh_token: refreshToken },
{
prefix: ClientPrefix.V1,
inhibitLogoutEmit: true, // we don't want to cause logout loops
},
);
const performRefreshRequestWithPrefix = (prefix: ClientPrefix): Promise<IRefreshTokenResponse> =>
this.http.authedRequest(
Method.Post,
"/refresh",
undefined,
{ refresh_token: refreshToken },
{
prefix,
inhibitLogoutEmit: true, // we don't want to cause logout loops
},
);
// First try with the (specced) /v3/ prefix.
// However, before Synapse 1.72.0, Synapse incorrectly required a /v1/ prefix, so we fall
// back to that if the request fails, for backwards compatibility.
return performRefreshRequestWithPrefix(ClientPrefix.V3).catch((e) => {
if (e.errcode === "M_UNRECOGNIZED") {
return performRefreshRequestWithPrefix(ClientPrefix.V1);
}
throw e;
});
}
/**
@@ -9572,11 +9652,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* A helper to determine intentional mentions support
* @returns a boolean to determine if intentional mentions are enabled
* @returns a boolean to determine if intentional mentions are enabled on the server
* @experimental
*/
public supportsIntentionalMentions(): boolean {
return this.clientOpts?.intentionalMentions || false;
return this.canSupport.get(Feature.IntentionalMentions) !== ServerSupport.Unsupported;
}
/**
-10
View File
@@ -21,7 +21,6 @@ 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
@@ -79,15 +78,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Get the cross signing information for a given user.
*
+243 -11
View File
@@ -18,13 +18,9 @@ import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
/** Types of cross-signing key */
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { VerificationRequest } from "./crypto-api/verification";
import { KeyBackupInfo } from "./crypto-api/keybackup";
/**
* Public interface to the cryptography parts of the js-sdk
@@ -82,6 +78,15 @@ export interface CryptoApi {
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
/**
* Import a list of room keys previously exported by exportRoomKeys
*
* @param keys - a list of session export objects
* @param opts - options object
* @returns a promise which resolves once the keys have been imported
*/
importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
/**
* Get the device information for the given list of users.
*
@@ -114,7 +119,7 @@ export interface CryptoApi {
/**
* Return whether we trust other user's signatures of their devices.
*
* @see {@link CryptoApi#setTrustCrossSignedDevices}
* @see {@link Crypto.CryptoApi#setTrustCrossSignedDevices}
*
* @returns `true` if we trust cross-signed devices, otherwise `false`.
*/
@@ -126,7 +131,8 @@ export interface CryptoApi {
* @param userId - The ID of the user whose device is to be checked.
* @param deviceId - The ID of the device to check
*
* @returns Verification status of the device, or `null` if the device is not known
* @returns `null` if the device is unknown, or has not published any encryption keys (implying it does not support
* encryption); otherwise the verification status of the device.
*/
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
@@ -147,7 +153,7 @@ export interface CryptoApi {
/**
* Get the ID of one of the user's cross-signing keys.
*
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigngingKey.SelfSigning`,
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigningKey.SelfSigning`,
* or `CrossSigningKey.UserSigning`. Defaults to `CrossSigningKey.Master`.
*
* @returns If cross-signing has been initialised on this device, the ID of the given key. Otherwise, null
@@ -184,6 +190,100 @@ export interface CryptoApi {
* @returns True if secret storage is ready to be used on this device
*/
isSecretStorageReady(): Promise<boolean>;
/**
* Bootstrap the secret storage by creating a new secret storage key, add it in the secret storage and
* store the cross signing keys in the secret storage.
*
* - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`.
* Only if `setupNewSecretStorage` is set or if there is no AES key in the secret storage
* - Store this key in the secret storage and set it as the default key.
* - Call `cryptoCallbacks.cacheSecretStorageKey` if provided.
* - Store the cross signing keys in the secret storage if
* - the cross signing is ready
* - a new key was created during the previous step
* - or the secret storage already contains the cross signing keys
*
* @param opts - Options object.
*/
bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise<void>;
/**
* Get the status of our cross-signing keys.
*
* @returns The current status of cross-signing keys: whether we have public and private keys cached locally, and whether the private keys are in secret storage.
*/
getCrossSigningStatus(): Promise<CrossSigningStatus>;
/**
* Create a recovery key (ie, a key suitable for use with server-side secret storage).
*
* The key can either be based on a user-supplied passphrase, or just created randomly.
*
* @param password - Optional passphrase string to use to derive the key,
* which can later be entered by the user as an alternative to entering the
* recovery key itself. If omitted, a key is generated randomly.
*
* @returns Object including recovery key and server upload parameters.
* The private key should be disposed of after displaying to the use.
*/
createRecoveryKeyFromPassphrase(password?: string): Promise<GeneratedSecretStorageKey>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Device/User verification
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Returns to-device verification requests that are already in progress for the given user id.
*
* @param userId - the ID of the user to query
*
* @returns the VerificationRequests that are in progress
*/
getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[];
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Send a verification request to our other devices.
*
* This is normally used when the current device is new, and we want to ask another of our devices to cross-sign.
*
* If an all-devices verification is already in flight, returns it. Otherwise, initiates a new one.
*
* To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the
* MatrixClient.
*
* @returns a VerificationRequest when the request has been sent to the other party.
*/
requestOwnUserVerification(): Promise<VerificationRequest>;
/**
* Request an interactive verification with the given device.
*
* This is normally used on one of our own devices, when the current device is already cross-signed, and we want to
* validate another device.
*
* If a verification for this user/device is already in flight, returns it. Otherwise, initiates a new one.
*
* To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the
* MatrixClient.
*
* @param userId - ID of the owner of the device to verify
* @param deviceId - ID of the device to verify
*
* @returns a VerificationRequest when the request has been sent to the other party.
*/
requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest>;
}
/**
@@ -252,7 +352,7 @@ export class DeviceVerificationStatus {
* A device is "verified" if either:
* * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
* cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
* cross-signed devices via {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
*
* @returns true if this device is verified via any means.
*/
@@ -260,3 +360,135 @@ export class DeviceVerificationStatus {
return this.localVerified || (this.trustCrossSignedDevices && this.crossSigningVerified);
}
}
/**
* Room key import progress report.
* Used when calling {@link CryptoApi#importRoomKeys} as the parameter of
* the progressCallback. Used to display feedback.
*/
export interface ImportRoomKeyProgressData {
stage: string; // TODO: Enum
successes: number;
failures: number;
total: number;
}
/**
* Options object for {@link CryptoApi#importRoomKeys}.
*/
export interface ImportRoomKeysOpts {
/** Reports ongoing progress of the import process. Can be used for feedback. */
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
// TODO, the rust SDK will always such imported keys as untrusted
untrusted?: boolean;
source?: String; // TODO: Enum (backup, file, ??)
}
/**
* The result of a call to {@link CryptoApi.getCrossSigningStatus}.
*/
export interface CrossSigningStatus {
/**
* True if the public master, self signing and user signing keys are available on this device.
*/
publicKeysOnDevice: boolean;
/**
* True if the private keys are stored in the secret storage.
*/
privateKeysInSecretStorage: boolean;
/**
* True if the private keys are stored locally.
*/
privateKeysCachedLocally: {
masterKey: boolean;
selfSigningKey: boolean;
userSigningKey: boolean;
};
}
/**
* Crypto callbacks provided by the application
*/
export interface CryptoCallbacks extends SecretStorageCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
/**
* Called by {@link CryptoApi#bootstrapSecretStorage}
* @param keyId - secret storage key id
* @param keyInfo - secret storage key info
* @param key - private key to store
*/
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
onSecretRequested?: (
userId: string,
deviceId: string,
requestId: string,
secretName: string,
deviceTrust: DeviceVerificationStatus,
) => Promise<string | undefined>;
getDehydrationKey?: (
keyInfo: SecretStorageKeyDescription,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
/**
* Parameter of {@link CryptoApi#bootstrapSecretStorage}
*/
export interface CreateSecretStorageOpts {
/**
* Function called to await a secret storage key creation flow.
* @returns Promise resolving to an object with public key metadata, encoded private
* recovery key which should be disposed of after displaying to the user,
* and raw private key to avoid round tripping if needed.
*/
createSecretStorageKey?: () => Promise<GeneratedSecretStorageKey>;
/**
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
*/
keyBackupInfo?: KeyBackupInfo;
/**
* If true, a new key backup version will be
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
* is supplied.
*/
setupNewKeyBackup?: boolean;
/**
* Reset even if keys already exist.
*/
setupNewSecretStorage?: boolean;
/**
* Function called to get the user's
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
* containing the key, or rejects if the key cannot be obtained.
*/
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
/** Types of cross-signing key */
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
/**
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
*/
export interface GeneratedSecretStorageKey {
keyInfo?: AddSecretStorageKeyOpts;
/** The raw generated private key. */
privateKey: Uint8Array;
/** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */
encodedPrivateKey?: string;
}
export * from "./crypto-api/verification";
export * from "./crypto-api/keybackup";
+42
View File
@@ -0,0 +1,42 @@
/*
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 { ISigned } from "../@types/signed";
export interface Curve25519AuthData {
public_key: string;
private_key_salt?: string;
private_key_iterations?: number;
private_key_bits?: number;
}
export interface Aes256AuthData {
iv: string;
mac: string;
private_key_salt?: string;
private_key_iterations?: number;
}
/**
* Extra info of a recovery key
*/
export interface KeyBackupInfo {
algorithm: string;
auth_data: ISigned & (Curve25519AuthData | Aes256AuthData);
count?: number;
etag?: string;
version?: string; // number contained within
}
+360
View File
@@ -0,0 +1,360 @@
/*
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 { MatrixEvent } from "../models/event";
import { TypedEventEmitter } from "../models/typed-event-emitter";
/**
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
*/
export interface VerificationRequest
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap> {
/**
* Unique ID for this verification request.
*
* An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases.
*/
get transactionId(): string | undefined;
/**
* For an in-room verification, the ID of the room.
*
* For to-device verifictions, `undefined`.
*/
get roomId(): string | undefined;
/**
* True if this request was initiated by the local client.
*
* For in-room verifications, the initiator is who sent the `m.key.verification.request` event.
* For to-device verifications, the initiator is who sent the `m.key.verification.start` event.
*/
get initiatedByMe(): boolean;
/** The user id of the other party in this request */
get otherUserId(): string;
/** For verifications via to-device messages: the ID of the other device. Otherwise, undefined. */
get otherDeviceId(): string | undefined;
/** True if the other party in this request is one of this user's own devices. */
get isSelfVerification(): boolean;
/** current phase of the request. */
get phase(): VerificationPhase;
/** True if the request has sent its initial event and needs more events to complete
* (ie it is in phase `Requested`, `Ready` or `Started`).
*/
get pending(): boolean;
/**
* True if we have started the process of sending an `m.key.verification.ready` (but have not necessarily received
* the remote echo which causes a transition to {@link VerificationPhase.Ready}.
*/
get accepting(): boolean;
/**
* True if we have started the process of sending an `m.key.verification.cancel` (but have not necessarily received
* the remote echo which causes a transition to {@link VerificationPhase.Cancelled}).
*/
get declining(): boolean;
/**
* The remaining number of ms before the request will be automatically cancelled.
*
* `null` indicates that there is no timeout
*/
get timeout(): number | null;
/** once the phase is Started (and !initiatedByMe) or Ready: common methods supported by both sides */
get methods(): string[];
/** the method picked in the .start event */
get chosenMethod(): string | null;
/**
* Checks whether the other party supports a given verification method.
* This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
* if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
* For methods that need to be supported by both ends, use the `methods` property.
*
* @param method - the method to check
* @returns true if the other party said they supported the method
*/
otherPartySupportsMethod(method: string): boolean;
/**
* Accepts the request, sending a .ready event to the other party
*
* @returns Promise which resolves when the event has been sent.
*/
accept(): Promise<void>;
/**
* Cancels the request, sending a cancellation to the other party
*
* @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code`
* (defaults to `m.user`). **Deprecated**: this parameter is ignored by the Rust cryptography implementation.
*
* @returns Promise which resolves when the event has been sent.
*/
cancel(params?: { reason?: string; code?: string }): Promise<void>;
/**
* Create a {@link Verifier} to do this verification via a particular method.
*
* If a verifier has already been created for this request, returns that verifier.
*
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Crypto.Verifier#verify} on the
* returned verifier.
*
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
*
* @param method - the name of the verification method to use.
* @param targetDevice - details of where to send the request to.
*
* @returns The verifier which will do the actual verification.
*/
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
/**
* The verifier which is doing the actual verification, once the method has been established.
* Only defined when the `phase` is Started.
*/
get verifier(): Verifier | undefined;
/**
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*/
getQRCodeBytes(): Buffer | undefined;
/**
* If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling
* this verification.
*/
get cancellationCode(): string | null;
/**
* The id of the user that cancelled the request.
*
* Only defined when phase is Cancelled
*/
get cancellingUserId(): string | undefined;
}
/** Events emitted by {@link VerificationRequest}. */
export enum VerificationRequestEvent {
/**
* Fires whenever the state of the request object has changed.
*
* There is no payload to the event.
*/
Change = "change",
}
/**
* Listener type map for {@link VerificationRequestEvent}s.
*
* @internal
*/
export type VerificationRequestEventHandlerMap = {
[VerificationRequestEvent.Change]: () => void;
};
/** The current phase of a verification request. */
export enum VerificationPhase {
/** Initial state: no event yet exchanged */
Unsent = 1,
/** An `m.key.verification.request` event has been sent or received */
Requested,
/** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */
Ready,
/** An `m.key.verification.start` event has been sent or received, choosing a verification method */
Started,
/** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */
Cancelled,
/** An `m.key.verification.done` event has been **sent**, completing the verification request. */
Done,
}
/**
* A `Verifier` is responsible for performing the verification using a particular method, such as via QR code or SAS
* (emojis).
*
* A verifier object can be created by calling `VerificationRequest.beginVerification`; one is also created
* automatically when a `m.key.verification.start` event is received for an existing VerificationRequest.
*
* Once a verifier object is created, the verification can be started by calling the {@link Verifier#verify} method.
*/
export interface Verifier extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap> {
/**
* Returns true if the verification has been cancelled, either by us or the other side.
*/
get hasBeenCancelled(): boolean;
/**
* The ID of the other user in the verification process.
*/
get userId(): string;
/**
* Start the key verification, if it has not already been started.
*
* This means sending a `m.key.verification.start` if we are the first responder, or a `m.key.verification.accept`
* if the other side has already sent a start event.
*
* @returns Promise which resolves when the verification has completed, or rejects if the verification is cancelled
* or times out.
*/
verify(): Promise<void>;
/**
* Cancel a verification.
*
* We will send an `m.key.verification.cancel` if the verification is still in flight. The verification promise
* will reject, and a {@link Crypto.VerifierEvent#Cancel} will be emitted.
*
* @param e - the reason for the cancellation.
*/
cancel(e: Error): void;
/**
* Get the details for an SAS verification, if one is in progress
*
* Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm
* the SAS matches.
*/
getShowSasCallbacks(): ShowSasCallbacks | null;
/**
* Get the details for reciprocating QR code verification, if one is in progress
*
* Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has
* already scanned our QR code), and we are waiting for the user to confirm.
*/
getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null;
}
/** Events emitted by {@link Verifier} */
export enum VerifierEvent {
/**
* The verification has been cancelled, by us or the other side.
*
* The payload is either an {@link Error}, or an (incoming or outgoing) {@link MatrixEvent}, depending on
* unspecified reasons.
*/
Cancel = "cancel",
/**
* SAS data has been exchanged and should be displayed to the user.
*
* The payload is the {@link ShowSasCallbacks} object.
*/
ShowSas = "show_sas",
/**
* QR code data should be displayed to the user.
*
* The payload is the {@link ShowQrCodeCallbacks} object.
*/
ShowReciprocateQr = "show_reciprocate_qr",
}
/** Listener type map for {@link VerifierEvent}s. */
export type VerifierEventHandlerMap = {
[VerifierEvent.Cancel]: (e: Error | MatrixEvent) => void;
[VerifierEvent.ShowSas]: (sas: ShowSasCallbacks) => void;
[VerifierEvent.ShowReciprocateQr]: (qr: ShowQrCodeCallbacks) => void;
};
/**
* Callbacks for user actions while a QR code is displayed.
*
* This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
* verifier as `reciprocateQREvent`.
*/
export interface ShowQrCodeCallbacks {
/** The user confirms that the verification data matches */
confirm(): void;
/** Cancel the verification flow */
cancel(): void;
}
/**
* Callbacks for user actions while a SAS is displayed.
*
* This is exposed as the payload of a `VerifierEvent.ShowSas` event, or directly from the verifier as `sasEvent`.
*/
export interface ShowSasCallbacks {
/** The generated SAS to be shown to the user */
sas: GeneratedSas;
/** Function to call if the user confirms that the SAS matches.
*
* @returns A Promise that completes once the m.key.verification.mac is queued.
*/
confirm(): Promise<void>;
/**
* Function to call if the user finds the SAS does not match.
*
* Sends an `m.key.verification.cancel` event with a `m.mismatched_sas` error code.
*/
mismatch(): void;
/** Cancel the verification flow */
cancel(): void;
}
/** A generated SAS to be shown to the user, in alternative formats */
export interface GeneratedSas {
/**
* The SAS as three numbers between 0 and 8191.
*
* Only populated if the `decimal` SAS method was negotiated.
*/
decimal?: [number, number, number];
/**
* The SAS as seven emojis.
*
* Only populated if the `emoji` SAS method was negotiated.
*/
emoji?: EmojiMapping[];
}
/**
* An emoji for the generated SAS. A tuple `[emoji, name]` where `emoji` is the emoji itself and `name` is the
* English name.
*/
export type EmojiMapping = [emoji: string, name: string];
/**
* True if the request is in a state where it can be accepted (ie, that we're in phases {@link VerificationPhase.Unsent}
* or {@link VerificationPhase.Requested}, and that we're not in the process of sending a `ready` or `cancel`).
*/
export function canAcceptVerificationRequest(req: VerificationRequest): boolean {
return req.phase < VerificationPhase.Ready && !req.accepting && !req.declining;
}
+2 -2
View File
@@ -688,7 +688,7 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O
_expectedPublicKey: string,
): Promise<Uint8Array> {
const key = await new Promise<any>((resolve) => {
return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.getSecretStorePrivateKey(txn, resolve, type);
});
});
@@ -790,7 +790,7 @@ export async function requestKeysDuringVerification(
})();
// We call getCrossSigningKey() for its side-effects
return Promise.race<KeysDuringVerification | void>([
Promise.race<KeysDuringVerification | void>([
Promise.all([
crossSigning.getCrossSigningKey("master"),
crossSigning.getCrossSigningKey("self_signing"),
+1 -1
View File
@@ -116,7 +116,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
public async load(): Promise<void> {
await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
this.hasFetched = Boolean(deviceData && deviceData.devices);
this.hasFetched = Boolean(deviceData?.devices);
this.devices = deviceData ? deviceData.devices : {};
this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {};
this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
+11 -57
View File
@@ -15,11 +15,20 @@ limitations under the License.
*/
import { DeviceInfo } from "./deviceinfo";
import { IKeyBackupInfo } from "./keybackup";
import type { AddSecretStorageKeyOpts } from "../secret-storage";
/* re-exports for backwards compatibility. */
// CrossSigningKey is used as a value in `client.ts`, we can't export it as a type
export { CrossSigningKey } from "../crypto-api";
export type {
GeneratedSecretStorageKey as IRecoveryKey,
CreateSecretStorageOpts as ICreateSecretStorageOpts,
} from "../crypto-api";
export type {
ImportRoomKeyProgressData as IImportOpts,
ImportRoomKeysOpts as IImportRoomKeysOpts,
} from "../crypto-api";
export type {
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
PassphraseInfo as IPassphraseInfo,
@@ -59,58 +68,3 @@ export interface IEncryptedEventInfo {
*/
mismatchedSender: boolean;
}
export interface IRecoveryKey {
keyInfo?: AddSecretStorageKeyOpts;
privateKey: Uint8Array;
encodedPrivateKey?: string;
}
export interface ICreateSecretStorageOpts {
/**
* Function called to await a secret storage key creation flow.
* @returns Promise resolving to an object with public key metadata, encoded private
* recovery key which should be disposed of after displaying to the user,
* and raw private key to avoid round tripping if needed.
*/
createSecretStorageKey?: () => Promise<IRecoveryKey>;
/**
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
*/
keyBackupInfo?: IKeyBackupInfo;
/**
* If true, a new key backup version will be
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
* is supplied.
*/
setupNewKeyBackup?: boolean;
/**
* Reset even if keys already exist.
*/
setupNewSecretStorage?: boolean;
/**
* Function called to get the user's
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
* containing the key, or rejects if the key cannot be obtained.
*/
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
export interface IImportOpts {
stage: string; // TODO: Enum
successes: number;
failures: number;
total: number;
}
export interface IImportRoomKeysOpts {
/** called with an object that has a "stage" param */
progressCallback?: (stage: IImportOpts) => void;
untrusted?: boolean;
source?: string; // TODO: Enum
}
+51 -36
View File
@@ -35,13 +35,7 @@ import * as algorithms from "./algorithms";
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
import { EncryptionSetupBuilder } from "./EncryptionSetup";
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
import {
CrossSigningKey,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
IRecoveryKey,
} from "./api";
import { CrossSigningKey, ICreateSecretStorageOpts, IEncryptedEventInfo, IRecoveryKey } from "./api";
import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { VerificationBase } from "./verification/Base";
@@ -86,19 +80,26 @@ import {
AccountDataClient,
AddSecretStorageKeyOpts,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorageCallbacks,
SecretStorageKeyDescription,
SecretStorageKeyObject,
SecretStorageKeyTuple,
ServerSideSecretStorageImpl,
} from "../secret-storage";
import { ISecretRequest } from "./SecretSharing";
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
import {
BootstrapCrossSigningOpts,
CrossSigningStatus,
DeviceVerificationStatus,
ImportRoomKeysOpts,
} from "../crypto-api";
import { Device, DeviceMap } from "../models/device";
import { deviceInfoToDevice } from "./device-converter";
/* re-exports for backwards compatibility */
export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api";
export type {
BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts,
CryptoCallbacks as ICryptoCallbacks,
} from "../crypto-api";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -135,25 +136,6 @@ interface IInitOpts {
pickleKey?: string;
}
export interface ICryptoCallbacks extends SecretStorageCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
onSecretRequested?: (
userId: string,
deviceId: string,
requestId: string,
secretName: string,
deviceTrust: DeviceTrustLevel,
) => Promise<string | undefined>;
getDehydrationKey?: (
keyInfo: SecretStorageKeyDescription,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
/* eslint-disable camelcase */
interface IRoomKey {
room_id: string;
@@ -611,7 +593,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* @deprecated Use {@link CryptoApi#getTrustCrossSignedDevices}.
* @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}.
*/
public getCryptoTrustCrossSignedDevices(): boolean {
return this.trustCrossSignedDevices;
@@ -641,7 +623,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* @deprecated Use {@link CryptoApi#setTrustCrossSignedDevices}.
* @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
this.setTrustCrossSignedDevices(val);
@@ -744,6 +726,30 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage);
}
/**
* Implementation of {@link CryptoApi#getCrossSigningStatus}
*/
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
const publicKeysOnDevice = Boolean(this.crossSigningInfo.getId());
const privateKeysInSecretStorage = Boolean(
await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage),
);
const cacheCallbacks = this.crossSigningInfo.getCacheCallbacks();
const masterKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("master"));
const selfSigningKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("self_signing"));
const userSigningKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("user_signing"));
return {
publicKeysOnDevice,
privateKeysInSecretStorage,
privateKeysCachedLocally: {
masterKey,
selfSigningKey,
userSigningKey,
},
};
}
/**
* Bootstrap cross-signing by creating keys if needed. If everything is already
* set up, then no changes are made, so this is safe to run to ensure
@@ -1473,7 +1479,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
const device = this.deviceList.getStoredDevice(userId, deviceId);
@@ -1486,7 +1492,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param userId - The ID of the user whose devices is to be checked.
* @param device - The device info object to check
*
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
*/
public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
const trustedLocally = !!device?.isVerified();
@@ -1835,7 +1841,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param value - whether to blacklist all unverified devices by default
*
* @deprecated Set {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
* @deprecated Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
*/
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
this.globalBlacklistUnverifiedDevices = value;
@@ -1844,7 +1850,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* @returns whether to blacklist all unverified devices by default
*
* @deprecated Reference {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
* @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
*/
public getGlobalBlacklistUnverifiedDevices(): boolean {
return this.globalBlacklistUnverifiedDevices;
@@ -2333,6 +2339,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests);
}
/** @deprecated Use `requestOwnUserVerificationToDevice` or `requestDeviceVerification` */
public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
if (!devices) {
devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId));
@@ -2345,6 +2352,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests);
}
public requestOwnUserVerification(): Promise<VerificationRequest> {
return this.requestVerification(this.userId);
}
public requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest> {
return this.requestVerification(userId, [deviceId]);
}
private async requestVerificationWithChannel(
userId: string,
channel: IVerificationChannel,
@@ -2828,7 +2843,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param keys - a list of session export objects
* @returns a promise which resolves once the keys have been imported
*/
public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts = {}): Promise<void> {
public importRoomKeys(keys: IMegolmSessionData[], opts: ImportRoomKeysOpts = {}): Promise<void> {
let successes = 0;
let failures = 0;
const total = keys.length;
+6 -21
View File
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ISigned } from "../@types/signed";
import { IEncryptedPayload } from "./aes";
export interface Curve25519SessionData {
@@ -35,27 +34,13 @@ export interface IKeyBackupRoomSessions {
[sessionId: string]: IKeyBackupSession;
}
export interface ICurve25519AuthData {
public_key: string;
private_key_salt?: string;
private_key_iterations?: number;
private_key_bits?: number;
}
// Export for backward compatibility
export type {
Curve25519AuthData as ICurve25519AuthData,
Aes256AuthData as IAes256AuthData,
KeyBackupInfo as IKeyBackupInfo,
} from "../crypto-api/keybackup";
export interface IAes256AuthData {
iv: string;
mac: string;
private_key_salt?: string;
private_key_iterations?: number;
}
export interface IKeyBackupInfo {
algorithm: string;
auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData);
count?: number;
etag?: string;
version?: string; // number contained within
}
/* eslint-enable camelcase */
export interface IKeyBackupPrepareOpts {
+1 -2
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { logger } from "../../logger";
import { deepCompare, promiseTry } from "../../utils";
import { safeSet, deepCompare, promiseTry } from "../../utils";
import {
CryptoStore,
IDeviceData,
@@ -33,7 +33,6 @@ import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
/**
* Internal module. in-memory storage for e2e.
+44 -7
View File
@@ -28,7 +28,14 @@ import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossS
import { IVerificationChannel } from "./request/Channel";
import { MatrixClient } from "../../client";
import { VerificationRequest } from "./request/VerificationRequest";
import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter";
import { TypedEventEmitter } from "../../models/typed-event-emitter";
import {
ShowQrCodeCallbacks,
ShowSasCallbacks,
Verifier,
VerifierEvent,
VerifierEventHandlerMap,
} from "../../crypto-api/verification";
const timeoutException = new Error("Verification timed out");
@@ -40,18 +47,28 @@ export class SwitchStartEventError extends Error {
export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void;
export enum VerificationEvent {
Cancel = "cancel",
}
/** @deprecated use VerifierEvent */
export type VerificationEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const VerificationEvent = VerifierEvent;
/** @deprecated use VerifierEventHandlerMap */
export type VerificationEventHandlerMap = {
[VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void;
};
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain
// backwards compatibility with applications that reference the class.
export class VerificationBase<
Events extends string,
Arguments extends ListenerMap<Events | VerificationEvent>,
> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Events extends string = VerifierEvent,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Arguments = VerifierEventHandlerMap,
>
extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap>
implements Verifier
{
private cancelled = false;
private _done = false;
private promise: Promise<void> | null = null;
@@ -366,4 +383,24 @@ export class VerificationBase<
public get events(): string[] | undefined {
return undefined;
}
/**
* Get the details for an SAS verification, if one is in progress
*
* Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm
* the SAS matches.
*/
public getShowSasCallbacks(): ShowSasCallbacks | null {
return null;
}
/**
* Get the details for reciprocating QR code verification, if one is in progress
*
* Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has
* already scanned our QR code), and we are waiting for the user to confirm.
*/
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
return null;
}
}
+13 -15
View File
@@ -18,7 +18,7 @@ limitations under the License.
* QR code key verification.
*/
import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base";
import { VerificationBase as Base } from "./Base";
import { newKeyMismatchError, newUserCancelledError } from "./Error";
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
import { logger } from "../../logger";
@@ -26,25 +26,19 @@ import { VerificationRequest } from "./request/VerificationRequest";
import { MatrixClient } from "../../client";
import { IVerificationChannel } from "./request/Channel";
import { MatrixEvent } from "../../models/event";
import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification";
export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
interface IReciprocateQr {
confirm(): void;
cancel(): void;
}
/** @deprecated use VerifierEvent */
export type QrCodeEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const QrCodeEvent = VerifierEvent;
export enum QrCodeEvent {
ShowReciprocateQr = "show_reciprocate_qr",
}
type EventHandlerMap = {
[QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void;
} & VerificationEventHandlerMap;
export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
public reciprocateQREvent?: IReciprocateQr;
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
export class ReciprocateQRCode extends Base {
public reciprocateQREvent?: ShowQrCodeCallbacks;
public static factory(
channel: IVerificationChannel,
@@ -126,6 +120,10 @@ export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
}
});
};
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
return this.reciprocateQREvent ?? null;
}
}
const CODE_VERSION = 0x02; // the version of binary QR codes we support
+22 -26
View File
@@ -21,7 +21,7 @@ limitations under the License.
import anotherjson from "another-json";
import { Utility, SAS as OlmSAS } from "@matrix-org/olm";
import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base";
import { VerificationBase as Base, SwitchStartEventError } from "./Base";
import {
errorFactory,
newInvalidMessageError,
@@ -33,6 +33,14 @@ import { logger } from "../../logger";
import { IContent, MatrixEvent } from "../../models/event";
import { generateDecimalSas } from "./SASDecimal";
import { EventType } from "../../@types/event";
import { EmojiMapping, GeneratedSas, ShowSasCallbacks, VerifierEvent } from "../../crypto-api/verification";
// backwards-compatibility exports
export type {
ShowSasCallbacks as ISasEvent,
GeneratedSas as IGeneratedSas,
EmojiMapping,
} from "../../crypto-api/verification";
const START_TYPE = EventType.KeyVerificationStart;
@@ -44,8 +52,6 @@ const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short
const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
type EmojiMapping = [emoji: string, name: string];
const emojiMapping: EmojiMapping[] = [
["🐶", "dog"], // 0
["🐱", "cat"], // 1
@@ -133,20 +139,8 @@ const sasGenerators = {
emoji: generateEmojiSas,
} as const;
export interface IGeneratedSas {
decimal?: [number, number, number];
emoji?: EmojiMapping[];
}
export interface ISasEvent {
sas: IGeneratedSas;
confirm(): Promise<void>;
cancel(): void;
mismatch(): void;
}
function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
const sas: IGeneratedSas = {};
function generateSas(sasBytes: Uint8Array, methods: string[]): GeneratedSas {
const sas: GeneratedSas = {};
for (const method of methods) {
if (method in sasGenerators) {
// @ts-ignore - ts doesn't like us mixing types like this
@@ -220,19 +214,17 @@ function intersection<T>(anArray: T[], aSet: Set<T>): T[] {
return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : [];
}
export enum SasEvent {
ShowSas = "show_sas",
}
/** @deprecated use VerifierEvent */
export type SasEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const SasEvent = VerifierEvent;
type EventHandlerMap = {
[SasEvent.ShowSas]: (sas: ISasEvent) => void;
} & VerificationEventHandlerMap;
export class SAS extends Base<SasEvent, EventHandlerMap> {
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
export class SAS extends Base {
private waitingForAccept?: boolean;
public ourSASPubKey?: string;
public theirSASPubKey?: string;
public sasEvent?: ISasEvent;
public sasEvent?: ShowSasCallbacks;
// eslint-disable-next-line @typescript-eslint/naming-convention
public static get NAME(): string {
@@ -489,4 +481,8 @@ export class SAS extends Base<SasEvent, EventHandlerMap> {
}
});
}
public getShowSasCallbacks(): ShowSasCallbacks | null {
return this.sasEvent ?? null;
}
}
@@ -125,7 +125,7 @@ export class InRoomChannel implements IVerificationChannel {
// part of a verification request, so be noisy when rejecting something
if (type === REQUEST_TYPE) {
if (!content || typeof content.to !== "string" || !content.to.length) {
logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to));
logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to);
return false;
}
@@ -134,7 +134,7 @@ export class InRoomChannel implements IVerificationChannel {
logger.log(
"InRoomChannel: validateEvent: " +
`not directed to or sent by me: ${event.getSender()}` +
`, ${content && content.to}`,
`, ${content.to}`,
);
return false;
}
@@ -208,10 +208,17 @@ export class InRoomChannel implements IVerificationChannel {
this.requestEventId = InRoomChannel.getTransactionId(event);
}
// With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected
// back via /sync. These are "local echoes" and are identifiable by their txnId
const isLocalEcho = !!event.getTxnId();
// Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes"
// and have a transaction ID in the "unsigned" data
const isRemoteEcho = !!event.getUnsigned().transaction_id;
const isSentByUs = event.getSender() === this.client.getUserId();
return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs);
}
/**
@@ -24,6 +24,16 @@ import { EventType } from "../../../@types/event";
import { VerificationBase } from "../Base";
import { VerificationMethod } from "../../index";
import { TypedEventEmitter } from "../../../models/typed-event-emitter";
import {
canAcceptVerificationRequest,
VerificationPhase as Phase,
VerificationRequest as IVerificationRequest,
VerificationRequestEvent,
VerificationRequestEventHandlerMap,
} from "../../../crypto-api/verification";
// backwards-compatibility exports
export { VerificationPhase as Phase, VerificationRequestEvent } from "../../../crypto-api/verification";
// How long after the event's timestamp that the request times out
const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes
@@ -44,15 +54,6 @@ export const CANCEL_TYPE = EVENT_PREFIX + "cancel";
export const DONE_TYPE = EVENT_PREFIX + "done";
export const READY_TYPE = EVENT_PREFIX + "ready";
export enum Phase {
Unsent = 1,
Requested,
Ready,
Started,
Cancelled,
Done,
}
// Legacy export fields
export const PHASE_UNSENT = Phase.Unsent;
export const PHASE_REQUESTED = Phase.Requested;
@@ -71,26 +72,17 @@ interface ITransition {
event?: MatrixEvent;
}
export enum VerificationRequestEvent {
Change = "change",
}
type EventHandlerMap = {
/**
* Fires whenever the state of the request object has changed.
*/
[VerificationRequestEvent.Change]: () => void;
};
/**
* State machine for verification requests.
* Things that differ based on what channel is used to
* send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`.
*
* @deprecated Avoid direct references: instead prefer {@link Crypto.VerificationRequest}.
*/
export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel> extends TypedEventEmitter<
VerificationRequestEvent,
EventHandlerMap
> {
export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel>
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap>
implements IVerificationRequest
{
private eventsByUs = new Map<string, MatrixEvent>();
private eventsByThem = new Map<string, MatrixEvent>();
private _observeOnly = false;
@@ -163,6 +155,22 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
return true;
}
/**
* Unique ID for this verification request.
*
* An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases.
*/
public get transactionId(): string | undefined {
return this.channel.transactionId;
}
/**
* For an in-room verification, the ID of the room.
*/
public get roomId(): string | undefined {
return this.channel.roomId;
}
public get invalid(): boolean {
return this.phase === PHASE_UNSENT;
}
@@ -241,7 +249,7 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
}
public get canAccept(): boolean {
return this.phase < PHASE_READY && !this._accepting && !this._declining;
return canAcceptVerificationRequest(this);
}
public get accepting(): boolean {
@@ -257,11 +265,23 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED;
}
/** Only set after a .ready if the other party can scan a QR code */
/** Only set after a .ready if the other party can scan a QR code
*
* @deprecated Prefer `getQRCodeBytes`.
*/
public get qrCodeData(): QRCodeData | null {
return this._qrCodeData;
}
/**
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*/
public getQRCodeBytes(): Buffer | undefined {
return this._qrCodeData?.getBuffer();
}
/** Checks whether the other party supports a given verification method.
* This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
* if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
@@ -347,6 +367,11 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
return this.channel.userId!;
}
/** The device id of the other party in this request, for requests happening over to-device messages only. */
public get otherDeviceId(): string | undefined {
return this.channel.deviceId;
}
public get isSelfVerification(): boolean {
return this.client.getUserId() === this.otherUserId;
}
+1 -2
View File
@@ -25,14 +25,13 @@ import {
ISendEventFromWidgetResponseData,
} from "matrix-widget-api";
import { IEvent, IContent, EventStatus } from "./models/event";
import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event";
import { ISendEventResponse } from "./@types/requests";
import { EventType } from "./@types/event";
import { logger } from "./logger";
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
import { SyncApi, SyncState } from "./sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { MatrixEvent } from "./models/event";
import { User } from "./models/user";
import { Room } from "./models/room";
import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
+4
View File
@@ -32,6 +32,7 @@ export enum Feature {
RelationBasedRedactions = "RelationBasedRedactions",
AccountDataDeletion = "AccountDataDeletion",
RelationsRecursion = "RelationsRecursion",
IntentionalMentions = "IntentionalMentions",
}
type FeatureSupportCondition = {
@@ -60,6 +61,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
[Feature.RelationsRecursion]: {
unstablePrefixes: ["org.matrix.msc3981"],
},
[Feature.IntentionalMentions]: {
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
+6 -2
View File
@@ -298,11 +298,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
* @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom".
* @param queryParams - A dict of query params (these will NOT be urlencoded).
* @param prefix - The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix.
* @param baseUrl - The baseUrl to use e.g. "https://matrix.org/", defaulting to this.opts.baseUrl.
* @param baseUrl - The baseUrl to use e.g. "https://matrix.org", defaulting to this.opts.baseUrl.
* @returns URL
*/
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
const baseUrlWithFallback = baseUrl ?? this.opts.baseUrl;
const baseUrlWithoutTrailingSlash = baseUrlWithFallback.endsWith("/")
? baseUrlWithFallback.slice(0, -1)
: baseUrlWithFallback;
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
if (queryParams) {
encodeParams(queryParams, url.searchParams);
}
+29 -4
View File
@@ -26,7 +26,7 @@ const EMAIL_STAGE_TYPE = "m.login.email.identity";
const MSISDN_STAGE_TYPE = "m.login.msisdn";
export interface UIAFlow {
stages: AuthType[];
stages: Array<AuthType | string>;
}
export interface IInputs {
@@ -156,6 +156,14 @@ interface IOpts {
*/
emailSid?: string;
/**
* If specified, will prefer flows which entirely consist of listed stages.
* These should normally be of type AuthTypes but can be string when supporting custom auth stages.
*
* This can be used to avoid needing the fallback mechanism.
*/
supportedStages?: Array<AuthType | string>;
/**
* Called with the new auth dict to submit the request.
* Also passes a second deprecated arg which is a flag set to true if this request is a background request.
@@ -176,7 +184,7 @@ interface IOpts {
* m.login.email.identity:
* * emailSid: string, the sid of the active email auth session
*/
stateUpdated(nextStage: AuthType, status: IStageStatus): void;
stateUpdated(nextStage: AuthType | string, status: IStageStatus): void;
/**
* A function that takes the email address (string), clientSecret (string), attempt number (int) and
@@ -216,6 +224,7 @@ export class InteractiveAuth {
private readonly busyChangedCallback?: IOpts["busyChanged"];
private readonly stateUpdatedCallback: IOpts["stateUpdated"];
private readonly requestEmailTokenCallback: IOpts["requestEmailToken"];
private readonly supportedStages?: Set<string>;
private data: IAuthData;
private emailSid?: string;
@@ -243,6 +252,7 @@ export class InteractiveAuth {
if (opts.sessionId) this.data.session = opts.sessionId;
this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret();
this.emailSid = opts.emailSid;
if (opts.supportedStages !== undefined) this.supportedStages = new Set(opts.supportedStages);
}
/**
@@ -571,7 +581,7 @@ export class InteractiveAuth {
* @returns login type
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
*/
private chooseStage(): AuthType | undefined {
private chooseStage(): AuthType | string | undefined {
if (this.chosenFlow === null) {
this.chosenFlow = this.chooseFlow();
}
@@ -581,6 +591,17 @@ export class InteractiveAuth {
return nextStage;
}
// Returns a low number for flows we consider best. Counts increase for longer flows and even more so
// for flows which contain stages not listed in `supportedStages`.
private scoreFlow(flow: UIAFlow): number {
let score = flow.stages.length;
if (this.supportedStages !== undefined) {
// Add 10 points to the score for each unsupported stage in the flow.
score += flow.stages.filter((stage) => !this.supportedStages!.has(stage)).length * 10;
}
return score;
}
/**
* Pick one of the flows from the returned list
* If a flow using all of the inputs is found, it will
@@ -603,6 +624,10 @@ export class InteractiveAuth {
const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid);
const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber);
// Flows are not represented in a significant order, so we can choose any we support best
// Sort flows based on how many unsupported stages they contain ascending
flows.sort((a, b) => this.scoreFlow(a) - this.scoreFlow(b));
for (const flow of flows) {
let flowHasEmail = false;
let flowHasMsisdn = false;
@@ -633,7 +658,7 @@ export class InteractiveAuth {
* @internal
* @returns login type
*/
private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
private firstUncompletedStage(flow: UIAFlow): AuthType | string | undefined {
const completed = this.data.completed || [];
return flow.stages.find((stageType) => !completed.includes(stageType));
}
+5 -1
View File
@@ -38,7 +38,11 @@ log.methodFactory = function (methodName, logLevel, loggerName) {
}
/* eslint-enable @typescript-eslint/no-invalid-this */
const supportedByConsole =
methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info";
methodName === "error" ||
methodName === "warn" ||
methodName === "trace" ||
methodName === "info" ||
methodName === "debug";
/* eslint-disable no-console */
if (supportedByConsole) {
return console[methodName](...args);
+24 -2
View File
@@ -37,6 +37,8 @@ export * from "./models/event-timeline-set";
export * from "./models/poll";
export * from "./models/room-member";
export * from "./models/room-state";
export * from "./models/thread";
export * from "./models/typed-event-emitter";
export * from "./models/user";
export * from "./models/device";
export * from "./scheduler";
@@ -63,10 +65,30 @@ export { createNewMatrixCall } from "./webrtc/call";
export type { MatrixCall } from "./webrtc/call";
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
export type { GroupCall } from "./webrtc/groupCall";
export type { CryptoApi } from "./crypto-api";
export { DeviceVerificationStatus } from "./crypto-api";
export { CryptoEvent } from "./crypto";
/**
* Types supporting cryptography.
*
* The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via
* {@link MatrixClient.getCrypto}.
*/
export * as Crypto from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.CryptoApi}
*/
export type { CryptoApi } from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.DeviceVerificationStatus}
*/
export { DeviceVerificationStatus } from "./crypto-api";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
/**
+1 -1
View File
@@ -27,7 +27,7 @@ export type DeviceMap = Map<string, Map<string, Device>>;
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
/**
* Information on a user's device, as returned by {@link CryptoApi.getUserDeviceInfo}.
* Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}.
*/
export class Device {
/** id of the device */
+93 -5
View File
@@ -721,13 +721,17 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
"in timelineSet(threadId=${this.thread?.id})`);
}
const eventId = event.getId()!;
this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this);
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
// threaded message should not be in the main timeline).
//
// We can only run this check for timelines with a `room` because `canContain`
// requires it
if (this.room && !this.canContain(event)) {
let eventDebugString = `event=${event.getId()}`;
let eventDebugString = `event=${eventId}`;
if (event.threadRootId) {
eventDebugString += `(belongs to thread=${event.threadRootId})`;
}
@@ -738,7 +742,6 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
return;
}
const eventId = event.getId()!;
timeline.addEvent(event, {
toStartOfTimeline,
roomState,
@@ -746,9 +749,6 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
});
this._eventIdToTimeline.set(eventId, timeline);
this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this);
const data: IRoomTimelineData = {
timeline: timeline,
liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache,
@@ -756,6 +756,94 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
}
/**
* Insert event to the given timeline, and emit Room.timeline. Assumes
* we have already checked we don't know about this event.
*
* TEMPORARY: until we have recursive relations, we need this function
* to exist to allow us to insert events in timeline order, which is our
* best guess for Sync Order.
* This is a copy of addEventToTimeline above, modified to insert the event
* after the event it relates to, and before any event with a later
* timestamp. This is our best guess at Sync Order.
*
* Will fire "Room.timeline" for each event added.
*
* @internal
*
* @param options - addEventToTimeline options
*
* @remarks
* Fires {@link RoomEvent.Timeline}
*/
public insertEventIntoTimeline(event: MatrixEvent, timeline: EventTimeline, roomState: RoomState): void {
if (timeline.getTimelineSet() !== this) {
throw new Error(`EventTimelineSet.insertEventIntoTimeline: Timeline=${timeline.toString()} does not belong " +
"in timelineSet(threadId=${this.thread?.id})`);
}
const eventId = event.getId()!;
this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this);
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
// threaded message should not be in the main timeline).
//
// We can only run this check for timelines with a `room` because `canContain`
// requires it
if (this.room && !this.canContain(event)) {
let eventDebugString = `event=${eventId}`;
if (event.threadRootId) {
eventDebugString += `(belongs to thread=${event.threadRootId})`;
}
logger.warn(
`EventTimelineSet.insertEventIntoTimeline: Ignoring ${eventDebugString} that does not belong ` +
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
);
return;
}
// Find the event that this event is related to - the "parent"
const parentEventId = event.relationEventId;
if (!parentEventId) {
// Not related to anything - we just append
this.addEventToTimeline(event, timeline, {
toStartOfTimeline: false,
fromCache: false,
timelineWasEmpty: false,
roomState,
});
return;
}
const parentEvent = this.findEventById(parentEventId);
const timelineEvents = timeline.getEvents();
// Start searching from the parent event, or if it's not loaded, start
// at the beginning and insert purely using timestamp order.
const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0;
let insertIndex = parentIndex;
for (; insertIndex < timelineEvents.length; insertIndex++) {
const nextEvent = timelineEvents[insertIndex];
if (nextEvent.getTs() > event.getTs()) {
// We found an event later than ours, so insert before that.
break;
}
}
// If we got to the end of the loop, insertIndex points at the end of
// the list.
timeline.insertEvent(event, insertIndex, roomState);
this._eventIdToTimeline.set(eventId, timeline);
const data: IRoomTimelineData = {
timeline: timeline,
liveEvent: timeline == this.liveTimeline,
};
this.emit(RoomEvent.Timeline, event, this.room, false, false, data);
}
/**
* Replaces event with ID oldEventId with one with newEventId, if oldEventId is
* recognised. Otherwise, add to the live timeline. Used to handle remote echos.
+39
View File
@@ -427,6 +427,45 @@ export class EventTimeline {
}
}
/**
* Insert a new event into the timeline, and update the state.
*
* TEMPORARY: until we have recursive relations, we need this function
* to exist to allow us to insert events in timeline order, which is our
* best guess for Sync Order.
* This is a copy of addEvent above, modified to allow inserting an event at
* a specific index.
*
* @internal
*/
public insertEvent(event: MatrixEvent, insertIndex: number, roomState: RoomState): void {
const timelineSet = this.getTimelineSet();
if (timelineSet.room) {
EventTimeline.setEventMetadata(event, roomState, false);
// modify state but only on unfiltered timelineSets
if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
roomState.setStateEvents([event], {});
// it is possible that the act of setting the state event means we
// can set more metadata (specifically sender/target props), so try
// it again if the prop wasn't previously set. It may also mean that
// the sender/target is updated (if the event set was a room member event)
// so we want to use the *updated* member (new avatar/name) instead.
//
// However, we do NOT want to do this on member events if we're going
// back in time, else we'll set the .sender value for BEFORE the given
// member event, whereas we want to set the .sender value for the ACTUAL
// member event itself.
if (!event.sender || event.getType() === EventType.RoomMember) {
EventTimeline.setEventMetadata(event, roomState, false);
}
}
}
this.events.splice(insertIndex, 0, event); // insert element
}
/**
* Remove an event from the timeline
*
+21 -5
View File
@@ -24,11 +24,17 @@ import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk";
import type { IEventDecryptionResult } from "../@types/crypto";
import { logger } from "../logger";
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
EventType,
MsgType,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { Crypto } from "../crypto";
import { deepSortedObjectEntries, internaliseString } from "../utils";
import { RoomMember } from "./room-member";
import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
import { Thread, ThreadEvent, ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
import { IActionsObject } from "../pushprocessor";
import { TypedReEmitter } from "../ReEmitter";
import { MatrixError } from "../http-api";
@@ -63,6 +69,7 @@ export interface IUnsigned {
"transaction_id"?: string;
"invite_room_state"?: StrippedState[];
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
}
export interface IThreadBundledRelationship {
@@ -572,9 +579,18 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
const relatesTo = this.getWireContent()?.["m.relates_to"];
if (relatesTo?.rel_type === THREAD_RELATION_TYPE.name) {
return relatesTo.event_id;
} else {
return this.getThread()?.id || this.threadId;
}
if (this.thread) {
return this.thread.id;
}
if (this.threadId !== undefined) {
return this.threadId;
}
const unsigned = this.getUnsigned();
if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") {
return unsigned[UNSIGNED_THREAD_ID_FIELD.name];
}
return undefined;
}
/**
@@ -586,7 +602,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// Bundled relationships only returned when the sync response is limited
// hence us having to check both bundled relation and inspect the thread
// model
return !!threadDetails || this.getThread()?.id === this.getId();
return !!threadDetails || this.threadRootId === this.getId();
}
public get replyEventId(): string | undefined {
+1 -1
View File
@@ -186,7 +186,7 @@ export class IgnoredInvites {
}
let regexp: RegExp;
try {
regexp = new RegExp(globToRegexp(glob, false));
regexp = new RegExp(globToRegexp(glob));
} catch (ex) {
// Assume invalid event.
continue;
+9
View File
@@ -309,4 +309,13 @@ export abstract class ReadReceipt<
// We don't know if the user has read it, so assume not.
return false;
}
/**
* Returns the most recent unthreaded receipt for a given user
* @param userId - the MxID of the User
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
* or a user chooses to use private read receipts (or we have simply not received
* a receipt from this user yet).
*/
public abstract getLastUnthreadedReceiptFor(userId: string): Receipt | undefined;
}
+82 -29
View File
@@ -39,6 +39,7 @@ import {
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
@@ -48,7 +49,7 @@ import { BeaconEvent, BeaconEventHandlerMap } from "./beacon";
import {
Thread,
ThreadEvent,
EventHandlerMap as ThreadHandlerMap,
ThreadEventHandlerMap as ThreadHandlerMap,
FILTER_RELATED_BY_REL_TYPES,
THREAD_RELATION_TYPE,
FILTER_RELATED_BY_SENDERS,
@@ -72,8 +73,8 @@ import { isPollEvent, Poll, PollEvent } from "./poll";
// room versions which are considered okay for people to run without being asked
// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
// return an m.room_versions capability.
export const KNOWN_SAFE_ROOM_VERSION = "9";
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
export const KNOWN_SAFE_ROOM_VERSION = "10";
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
interface IOpts {
/**
@@ -809,7 +810,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
}
/**
@@ -1956,7 +1957,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
this.on(ThreadEvent.NewReply, this.onThreadReply);
this.on(ThreadEvent.Delete, this.onThreadDelete);
this.threadsReady = true;
}
@@ -2007,6 +2008,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const poll = new Poll(event, this.client, this);
this.polls.set(event.getId()!, poll);
this.emit(PollEvent.New, poll);
// remove the poll when redacted
event.once(MatrixEventEvent.BeforeRedaction, (redactedEvent: MatrixEvent) => {
this.polls.delete(redactedEvent.getId()!);
});
} catch {}
// poll creation can fail for malformed poll start events
return;
@@ -2054,7 +2060,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
private onThreadNewReply(thread: Thread): void {
private onThreadReply(thread: Thread): void {
this.updateThreadRootEvents(thread, false, true);
}
@@ -2112,12 +2118,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
}
// A thread relation is always only shown in a thread
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
// A thread relation (1st and 2nd order) is always only shown in a thread
const threadRootId = event.threadRootId;
if (threadRootId != undefined) {
return {
shouldLiveInRoom: false,
shouldLiveInThread: true,
threadId: event.threadRootId,
threadId: threadRootId,
};
}
@@ -2132,6 +2139,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.eventShouldLiveIn(parentEvent, events, roots);
}
if (!event.isRelation()) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
// Edge case where we know the event is a relation but don't have the parentEvent
if (roots?.has(event.relationEventId!)) {
return {
@@ -2141,9 +2155,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
}
// We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
// We've exhausted all scenarios,
// we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
// adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
return {
shouldLiveInRoom: true,
shouldLiveInRoom: false,
shouldLiveInThread: false,
};
}
@@ -2156,14 +2172,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
let thread = this.getThread(threadId);
if (!thread) {
const thread = this.getThread(threadId);
if (thread) {
thread.addEvents(events, toStartOfTimeline);
} else {
const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId);
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
this.createThread(threadId, rootEvent, events, toStartOfTimeline);
}
thread.addEvents(events, toStartOfTimeline);
}
/**
@@ -2700,16 +2715,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @param addLiveEventOptions - addLiveEvent options
* @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'.
*/
public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void;
public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise<void>;
/**
* @deprecated In favor of the overload with `IAddLiveEventOptions`
*/
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void;
public addLiveEvents(
public async addLiveEvents(
events: MatrixEvent[],
duplicateStrategy?: DuplicateStrategy,
fromCache?: boolean,
): Promise<void>;
public async addLiveEvents(
events: MatrixEvent[],
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
fromCache = false,
): void {
): Promise<void> {
let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean | undefined = false;
if (typeof duplicateStrategyOrOpts === "object") {
@@ -2760,6 +2779,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
timelineWasEmpty,
};
// List of extra events to check for being parents of any relations encountered
const neighbouringEvents = [...events];
for (const event of events) {
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
this.processLiveEvent(event);
@@ -2773,12 +2795,35 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
let { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
event,
events,
neighbouringEvents,
threadRoots,
);
if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) {
try {
const parentEvent = new MatrixEvent(
await this.client.fetchRoomEvent(this.roomId, event.relationEventId!),
);
neighbouringEvents.push(parentEvent);
if (parentEvent.threadRootId) {
threadRoots.add(parentEvent.threadRootId);
const unsigned = event.getUnsigned();
unsigned[UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId;
event.setUnsigned(unsigned);
}
({ shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
event,
neighbouringEvents,
threadRoots,
));
} catch (e) {
logger.error("Failed to load parent event of unhandled relation", e);
}
}
if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
eventsByThread[threadId ?? ""] = [];
}
@@ -2786,6 +2831,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
if (shouldLiveInRoom) {
this.addLiveEvent(event, options);
} else if (!shouldLiveInThread && event.isRelation()) {
this.relations.aggregateChildEvent(event);
}
}
@@ -2796,13 +2843,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public partitionThreadedEvents(
events: MatrixEvent[],
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] {
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], unknownRelations: MatrixEvent[]] {
// Indices to the events array, for readability
const ROOM = 0;
const THREAD = 1;
const UNKNOWN_RELATION = 2;
if (this.client.supportsThreads()) {
const threadRoots = this.findThreadRoots(events);
return events.reduce(
return events.reduce<[MatrixEvent[], MatrixEvent[], MatrixEvent[]]>(
(memo, event: MatrixEvent) => {
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
event,
@@ -2819,13 +2867,17 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
memo[THREAD].push(event);
}
if (!shouldLiveInThread && !shouldLiveInRoom) {
memo[UNKNOWN_RELATION].push(event);
}
return memo;
},
[[] as MatrixEvent[], [] as MatrixEvent[]],
[[], [], []],
);
} else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
return [events as MatrixEvent[], [] as MatrixEvent[]];
return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]];
}
}
@@ -2835,8 +2887,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private findThreadRoots(events: MatrixEvent[]): Set<string> {
const threadRoots = new Set<string>();
for (const event of events) {
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
threadRoots.add(event.relationEventId ?? "");
const threadRootId = event.threadRootId;
if (threadRootId != undefined) {
threadRoots.add(threadRootId);
}
}
return threadRoots;

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