Compare commits

..

250 Commits

Author SHA1 Message Date
RiotRobot f576a9f2e9 v19.3.0-rc.1 2022-08-09 17:03:34 +01:00
RiotRobot fed121b0aa Prepare changelog for v19.3.0-rc.1 2022-08-09 17:03:34 +01:00
3nprob 3e37c74264 Document where new linting rules go (#2573) 2022-08-09 09:59:17 +02:00
Faye Duxovni 3762c20aad Revert "Always block sending keys to unverified devices of verified users (#2562)" (#2571)
This will be rolled out again later with more accompanying UI adjustments, including clearer error messages and possibly the option to disable it per-room.
2022-08-08 12:27:41 -04:00
Matthew Hodgson c96f1ba22b Merge pull request #2569 from matrix-org/matthew/avoid-sync-overlaps
Don't load the sync accumulator if there's already a sync persist in flight
2022-08-08 10:18:39 +01:00
Matthew Hodgson 43c81358b2 don't load the sync accumulator if there's already a sync persist in flight
this should hopefully reduce chances of
https://github.com/vector-im/element-web/issues/21541 a bit more
as we were incorrectly loading the sync accumulator even
if a sync persist was already in flight, thus wasting RAM
and increasing the chance of the renderer process OOMing
2022-08-07 01:13:06 +01:00
Šimon Brandner 6316a6ae44 Add support for stable prefixes for MSC2285 (#2524)
Co-authored-by: Travis Ralston <travisr@matrix.org>
2022-08-05 17:33:49 +02:00
David Baker 575b416856 Simplify encryptAndSendToDevices (#2566)
It went to quite a lot of effort to gather a bunch of information to
return, but the only thing using it already had all that info anyway.
2022-08-05 15:58:33 +01:00
David Baker 7b7f8c1592 Increase timeout to try & avoid flakiness in queueToDevice test (#2565)
https://github.com/matrix-org/matrix-js-sdk/issues/2561
2022-08-05 15:35:29 +01:00
David Baker 3907d1c28f Fix output after test finished (#2564)
* Fix output after test finished

As per comment

* Add more flushes
2022-08-04 17:28:42 +01:00
Robin c629d2f60e Emit an event when the client receives TURN servers (#2529)
* Emit an event when the client receives TURN servers

* Add tests

* Fix lints
2022-08-04 11:44:10 -04:00
Faye Duxovni 43b453804b Always block sending keys to unverified devices of verified users (#2562) 2022-08-04 11:11:12 -04:00
Šimon Brandner d867affc40 Remove stream-replacement (#2551) 2022-08-03 21:45:37 +02:00
Robin c36bfc821c Add support for sending user-defined encrypted to-device messages (#2528)
* Add support for sending user-defined encrypted to-device messages

This is a port of the same change from the robertlong/group-call branch.

* Fix tests

* Expose the method in MatrixClient

* Fix a code smell

* Fix types

* Test the MatrixClient method

* Fix some types in Crypto test suite

* Test the Crypto method

* Fix tests

* Upgrade matrix-mock-request

* Move useRealTimers to afterEach
2022-08-03 16:16:48 +00:00
David Baker 7e784da00a Retry to-device messages (#2549)
* Retry to-device messages

This adds a queueToDevice API alongside sendToDevice which is a
much higher-level API that adds the messages to a queue, stored in
persistent storage, and retries them periodically. Also converts
sending of megolm keys to use the new API.

Other uses of sendToDevice are nopt converted in this PR, but could
be later.

Requires https://github.com/matrix-org/matrix-mock-request/pull/17

* Bump matrix-mock-request

* Add more waits to make indexeddb tests pass

* Switch some test expectations to queueToDevice

* Stop straight away if the client has been stopped

Hopefully will fix tests being flakey and logging after tests have
finished.

* Add return types & fix constant usage

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Stop the client in all test cases

Co-authored-by: Germain <germains@element.io>
2022-08-03 13:32:58 +01:00
Germain b79f469008 Use EventType enum values instead of hardcoded strings (#2557) 2022-08-03 08:54:11 +00:00
RiotRobot cf33569a21 Resetting package fields for development 2022-08-02 17:01:12 +01:00
RiotRobot fb0a0c66c8 Merge branch 'master' into develop 2022-08-02 17:01:11 +01:00
RiotRobot aac0023338 v19.2.0 2022-08-02 16:58:54 +01:00
RiotRobot e3873ddef5 Prepare changelog for v19.2.0 2022-08-02 16:58:53 +01:00
kegsay f0991348e2 Merge pull request #2555 from matrix-org/kegan/sync-v3
Sliding sync: add missing filters from latest MSC
2022-08-02 14:54:37 +01:00
Kegan Dougal fa6708c27e Gracefully handle missing room_ids 2022-08-01 16:34:11 +01:00
Kegan Dougal 4427201326 Sliding sync: add missing filters from latest MSC 2022-08-01 16:30:33 +01:00
Kerry 4a4241806e test typescriptification - autodiscovery / crypto specs (#2550)
* spec/unit/autodiscovery.spec.js -> spec/unit/autodiscovery.spec.ts

* fix ts in autodiscovery.spec

* renamed:    spec/unit/crypto.spec.js -> spec/unit/crypto.spec.ts

* fix ts in crypto.spec

* fix some strict errors
2022-07-29 09:11:01 +00:00
David Baker 3824f65d15 Prevent double mute status changed events (#2502) (#2522)
Audio & video mute status were set in separate calls but share a
mute status changed event, so you'd always get two mute status
changed events emitted. We could suppress events where the mute
status didn't change, but this would still get two events saying
the same thing when they both changed. Instead, merge setAudioMuted
& setVideoMuted into a single call that sets either or both.

Port of https://github.com/matrix-org/matrix-js-sdk/pull/2502 from
group call branch
2022-07-28 16:13:00 +01:00
Michael Telatynski 3c17e4a6d6 Use consolidated Renovate config (#2548)
* Delete renovate.json

* Create renovate.json
2022-07-28 08:13:02 +02:00
Kerry 75513d08de test typescriptification - misc (#2547)
* renamed:    spec/unit/login.spec.js -> spec/unit/login.spec.ts

* type test client

* renamed:    spec/unit/interactive-auth.spec.js -> spec/unit/interactive-auth.spec.ts

* fix ts issues in interactive-auth.spec

* renamed:    spec/unit/filter.spec.js -> spec/unit/filter.spec.ts

* fix ts in filter.spec

* renamed:    spec/unit/event.spec.js -> spec/unit/event.spec.ts

* ts in event.spec

* renamed:    spec/unit/pushprocessor.spec.js -> spec/unit/pushprocessor.spec.ts

* fix ts in pushprocessor.spec

* fix ts in realtime-callbacks.spec

* renamed:    spec/unit/content-repo.spec.js -> spec/unit/content-repo.spec.ts

* fix signature for getHttpUriForMxc

* pr fixes
2022-07-28 08:09:21 +02:00
Šimon Brandner 7cb3b40493 Use stable prefixes for MSC3827 (#2537) 2022-07-27 20:10:39 +02:00
Kerry ab89804c55 test typescriptification: unit/crypto/algorithm specs (#2538)
* typescriptify megolm.spec

* add copyright

* renamed:    spec/unit/crypto/algorithms/olm.spec.js -> spec/unit/crypto/algorithms/olm.spec.ts

* fix ts issues in olm.spec

* remove comment

* more types in megolm and olm specs
2022-07-27 17:43:17 +02:00
renovate[bot] ab6cf93c2b Lock file maintenance (#2546)
* Lock file maintenance

* Empty commit to retry CI

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin Townsend <robin@robin.town>
2022-07-27 15:17:00 +00:00
Kerry 4c80762e22 test typescriptification - timeline-window, scheduler, etc (#2539)
* spec/unit/user.spec.js -> spec/unit/user.spec.ts

* fix ts in user.spec

* renamed:    spec/unit/timeline-window.spec.js -> spec/unit/timeline-window.spec.ts

* overdo it fixing types in timeline-window.spec

* renamed spec/unit/sync-accumulator.spec.js spec/unit/sync-accumulator.spec.ts

* fix ts in sync-accumalator.spec

* spec/unit/scheduler.spec.js -> spec/unit/scheduler.spec.ts

* fix ts in scheduler.spec

* missed types in timeline-window spec
2022-07-27 15:10:20 +00:00
Michael Telatynski 1f7e80c68d Require confirmation when doing proper release when intending to make an RC (#2540) 2022-07-27 09:12:57 +01:00
renovate[bot] e91b879a69 Update typescript-eslint monorepo to v5.31.0 (#2544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 17:45:10 +00:00
renovate[bot] 14885ba7a2 Update dependency @types/jest to v28.1.6 (#2543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 17:37:13 +00:00
renovate[bot] 0dda187d96 Update all (#2541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 17:30:10 +00:00
renovate[bot] 680d8cac4d Update babel monorepo to v7.18.9 (#2542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-26 18:16:53 +01:00
RiotRobot a7fd7fd539 v19.2.0-rc.1 2022-07-26 17:22:49 +01:00
RiotRobot 300d8b026a Prepare changelog for v19.2.0-rc.1 2022-07-26 17:22:48 +01:00
RiotRobot d5a15ac275 Resetting package fields for development 2022-07-26 16:12:26 +01:00
RiotRobot bbb5294b3b Merge branch 'master' into develop 2022-07-26 16:12:25 +01:00
RiotRobot 7731579796 v19.1.0 2022-07-26 16:08:28 +01:00
RiotRobot 55ab38a097 Prepare changelog for v19.1.0 2022-07-26 16:08:27 +01:00
Faye Duxovni 5367ee18fb Re-insert room IDs when decrypting bundled redaction events returned by /sync (#2531) 2022-07-21 10:55:20 +00:00
Faye Duxovni 45db39ec88 Rewrite megolm integration tests with async arrow functions (#2519) 2022-07-21 10:41:46 +00:00
renovate[bot] 32f55de383 Update dependency terser to v5.14.2 [SECURITY] (#2533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-21 08:57:07 +02:00
Hubert Chathi 7e8dfa56d0 Support fixed base64 in SAS verification (#2320) 2022-07-20 09:16:40 -04:00
Faye Duxovni 32bb4b1fc4 Typescriptify megolm integration tests (#2518) 2022-07-14 15:36:34 +00:00
renovate[bot] ae9bb6f27f Lock file maintenance (#2523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-13 15:58:11 +00:00
Travis Ralston 08ab51eeac Remove unstable support for m.room_key.withheld (#2512)
We no longer send or receive the unstable type.
2022-07-13 08:56:01 -06:00
renovate[bot] aa130c88da Update all (major) (#2517)
* Update all

* Pin p-retry due to ESM weirdness

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Travis Ralston <travisr@matrix.org>
2022-07-13 08:16:28 -04:00
Michael Telatynski 9523978861 Update release.sh to support a staging branch (#2514) 2022-07-13 09:43:12 +01:00
Robin 5112340040 Correct the units in TURN servers expiry documentation (#2520)
As shown elsewhere in client.ts, turnServersExpiry really is in
milliseconds rather than seconds. It seems that other libraries like
matrix-react-sdk were already expecting it to be in milliseconds
anyways, so it's just the documentation that was wrong.
2022-07-12 18:48:44 +00:00
Faye Duxovni 6fb40d465e Typescriptify crypto integration tests (#2508) 2022-07-12 12:18:39 -04:00
kegsay 8d7eaa769a Add support for MSC3575: Sliding Sync (#2242)
* sliding sync: add client function and add stub sliding-sync.ts

Mostly c/p from sync.ts. Define interfaces for MSC3575 sliding
sync types. Complete WIP!

* Add core sliding sync classes

* Add integration tests for sliding sync api basics

* gut unused code; add more types

* Use SlidingSync in MatrixClient; stub functions for Sync

Enough to make ele-web actually load okay with 0 rooms.

* Start feeding through room data to the client

* Bugfixes so it sorta ish works

* Refactor the public API for sliding sync

Still needs some work but it's a start.

* Use EventEmitter for callbacks. Add ability to adjust lists and listen for list updates.

- Have atomic getList/setList operations on SlidingSync to update windows etc
- Add a list callback which is invoked with the list indicies and joined count.

* Add stub tests; add listenUntil to make tests easier to read

* No need to resend now

* Add more sliding sync tests; add new setListRanges function

* build tests upon one another to reduce boilerplate and c/p

* More thorough sliding sync tests

* Dependency inject SlidingSync in Client opts when calling startClient()

* Linting

* Fix crash when opts is undefined

* Fix up docs to make CI happy

* Remove all listeners when stop()d to allow for GC

* Add support for extensions

* Add ExtensionE2EE automatically if opts.crypto is present

* Add ExtensionToDevice automatically

* Bugfixes for to_device message processing

* default events to []

* bugfix: don't tightloop when the server is down

Caused by not detecting abort() correctly

* Return null for bad index positions

* Add getListData to get the initial calculated list response

* Add is_tombstoned

* More comments

* Add support for account data extension; rejig extension interface

* Handle invite_state

* Feed through prev_batch tokens

* Linting

* Fix tests

* Linting

* Iterate PR

* Iterate tests and remove unused code

* Update matrix-mock-request

* Make tests happier

* Remove DEBUG/debuglog and use logger.debug

* Update the API to the latest MSC; fixup tests

* Use undefined not null to make it work with the latest changes

* Don't recreate rooms when initial: true

* Add defensive code when unsigned.transaction_id is missing

We can still pair up events by looking at the event_id. We need
to do this in Sliding Sync because the proxy has limitations that
means it cannot guarantee it will always incude a transaction_id
in unsigned. The main reason why is due to the following race condition:
 - A and B are in a DM room.
 - Both are using the proxy.
 - A says "hello".
 - B's sync stream gets "hello" on the proxy. At this point the proxy
   knows it needs to deliver it to A. It does so, but this event has
   no transaction_id as it came down B's sync stream, not A's.
 - If instead, A's sync stream gets "hello" on the proxy, the proxy
   will deliver this message with the transaction_id correctly set.

There are no guarantees that A's sync stream will get the event in a
timely manner, hence the decision to just deliver the events as soon
as the proxy gets the event. This will not be an issue for native
Sliding Sync implementations; this is just a proxy issue.

* Linting

* Add additional sliding sync tests

* Begin adding SlidingSyncSdk tests

* Linting

* Add more sliding sync sdk tests

* Prep work for extension tests

* Linting

* Add account data extension tests

* add to-device tests

* Add E2EE extension tests

* Code smell fixes and extra tests

* Add test for no-txn-id local echo

* Add tests for resolveProfilesToInvites

* Add tests for moving entries down as well as up the list

* Remove conn-management.ts

* Actually verify the event was removed from the txn map

* Handle the case when /sync returns before /send without a txn_id

And ensure all the tests actually test the right things.

* Linting

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-07-12 14:09:58 +00:00
renovate[bot] 7a18991342 Update dependency eslint to v8.19.0 (#2516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-12 13:45:39 +00:00
renovate[bot] f18c64db9e Update typescript-eslint monorepo to v5.30.6 (#2515)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-12 13:38:04 +00:00
RiotRobot 7c560b6daa v19.1.0-rc.1 2022-07-12 14:03:09 +01:00
RiotRobot de2add5d5d Prepare changelog for v19.1.0-rc.1 2022-07-12 14:03:08 +01:00
Travis Ralston 24710ee2fc Add a basic PR checklist for all PRs (#2511)
It'll be mildly annoying for core developers who have to constantly remove or edit this, but it'll also serve as a good reminder to do these things.

Note that signoff is not required for core developers.
2022-07-11 14:59:05 -06:00
Šimon Brandner 1fbfdaf221 Don't crash with undefined room in processBeaconEvents() (#2500) 2022-07-11 10:03:44 +02:00
Šimon Brandner c4f7e4d5aa Remove dead code (#2510) 2022-07-11 09:46:50 +02:00
Šimon Brandner 9a6dccb79b Remove setNow from realtime-callbacks.ts (#2509)
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-10 14:31:48 +02:00
Faye Duxovni 3935152d08 Properly re-insert room ID in bundled thread relation messages from sync (#2505)
Events returned by the `/sync` endpoint, including relations bundled with other events, may have their `room_id`s stripped out. This causes decryption errors if the IDs aren't repopulated.

Fixes vector-im/element-web#22094.
2022-07-08 22:43:38 +00:00
Travis Ralston 72f9a51c27 Actually store the identity server in the client when given as an option (#2503)
* Actually store the identity server in the client when given as an option

* Update requestRegisterEmailToken to a modern spec version too
2022-07-08 01:07:28 -06:00
Travis Ralston efdda8425d Remove MSC3244 support (#2504) 2022-07-08 00:32:27 -06:00
Šimon Brandner 685cab38b9 Improve VoIP integrations testing (#2495) 2022-07-07 08:38:17 +02:00
RiotRobot 85a96c6467 Resetting package fields for development 2022-07-05 14:09:11 +01:00
RiotRobot 2f832a9bfe Merge branch 'master' into develop 2022-07-05 14:09:11 +01:00
RiotRobot 1cb32c174b v19.0.0 2022-07-05 14:06:48 +01:00
RiotRobot b899fd6ccc Prepare changelog for v19.0.0 2022-07-05 14:06:48 +01:00
renovate[bot] f4aecb317f Lock file maintenance (#2491)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-04 12:15:41 +00:00
Michael Telatynski ee0264f77d Update pull_request.yaml (#2490) 2022-07-04 10:42:39 +01:00
texuf 9bf8b936d4 Fix return type on funcs in matrixClient to be optionally null (#2488) 2022-07-02 09:11:54 +01:00
Michael Weimann 9f01c8d1fb Expose KNOWN_SAFE_ROOM_VERSION (#2474) 2022-06-30 08:50:14 +02:00
renovate[bot] df5ab4fa91 Update babel monorepo to v7.18.6 (#2477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-06-29 16:14:04 -06:00
David Baker 39465b50cb Go back to forEach in collectcallstats (#2481)
Older typescript library doesn't know about .values() on the stats
object, so it was failing in react sdk which had an older typescript.
https://github.com/matrix-org/matrix-react-sdk/pull/8935 was an
attempt to upgrade it but did not seem to be helping on CI, despite
being fine locally.
2022-06-29 17:33:09 +01:00
David Baker a745c67dec Fix call.collectCallStats() (#2480)
Regressed by https://github.com/matrix-org/matrix-js-sdk/pull/2352
(you can just use RTCStatsReport as an iterator directly (which
was was what that code was doing before) which uses entries(
which gives you key/value pairs, but using forEach gives you just
the value.
2022-06-29 12:38:48 +01:00
renovate[bot] 55bec4fbe9 Update dependency @types/jest to v28 (#2478)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-06-28 15:53:36 +00:00
renovate[bot] 3a40348860 Update all (#2475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-06-28 15:52:40 +00:00
renovate[bot] 98262853c7 Update jest monorepo (#2476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-06-28 15:45:36 +00:00
RiotRobot 4b3aac21db v19.0.0-rc.1 2022-06-28 16:06:58 +01:00
RiotRobot 7521f82cac Prepare changelog for v19.0.0-rc.1 2022-06-28 16:06:58 +01:00
Kerry 9b7628c103 test typescriptification - backup.spec (#2468)
* renamed:    spec/unit/crypto/crypto-utils.js -> spec/unit/crypto/crypto-utils.ts

* ts fixes in crypto-utils

* renamed:    spec/unit/crypto/backup.spec.js -> spec/unit/crypto/backup.spec.ts

* ts fixes in backup.spec

* remove fit

* remove debug
2022-06-27 07:13:18 +00:00
Šimon Brandner 7d2f4cfd42 Send call version 1 as a string (#2471) 2022-06-26 08:43:01 +02:00
Šimon Brandner 5822730797 Implement MSC3827: Filtering of /publicRooms by room type (#2469) 2022-06-20 16:21:46 +02:00
Kerry fc946ab0fa expose latestLocationEvent on beacon model (#2467) 2022-06-17 13:39:23 +02:00
Kerry 9b843daf2f Live location share - add start time leniency (PSF-1081) (#2465)
* remove some of the confusing time travel in beacon.spec

* test cases

* add start time leniency to beacon liveness check
2022-06-16 15:00:45 +02:00
Michael Telatynski ab588f0e51 Fix issue with getEventTimeline returning undefined for thread roots in main timeline (#2454)
* Fix test message utils using overload

* Tweak existing tests

* Add test around `MatrixClient::getEventTimeline`

* Fix test to actually exercise the faulty behaviour

* Extract timelineSet thread belongs logic and test it

* tweak method name
2022-06-15 14:46:08 +00:00
Michael Telatynski b43b4aa9f9 Log real errors and not just their messages, traces are useful (#2464) 2022-06-15 12:44:42 +00:00
Travis Ralston d3ff7655f7 Add missing type property on IAuthData (#2463)
Per spec, for example: https://spec.matrix.org/v1.2/client-server-api/#dummy-auth
2022-06-15 00:37:02 -06:00
Travis Ralston a1ab0d42fe Clearly indicate that lastReply on a Thread can return falsy (#2462) 2022-06-14 16:12:37 -06:00
Michael Telatynski b9ca3ceacd Remove unused sessionStore (#2455)
* Remove unused sessionStorage layer

* Move pending event abstraction into its temporary home

* Add test coverage

* Tweak

* Fix tests mocks

* Add coverage

* Add coverage
2022-06-14 21:29:21 +01:00
Michael Telatynski eb8491c91b Skip running jobs on fork develop where they lack secrets (#2460)
* Skip running jobs on fork `develop` where they lack secrets

* Fix contexts
2022-06-14 11:30:57 +01:00
Jonathan de Jong 78db74dad8 Various changes to src/crypto files for correctness (#2137)
* make various changes for correctness

* apply some review feedback

* Address some review feedback

* add some more correctness

* refactor ensureOutboundSession to fit types better

* change variable naming slightly to prevent confusion

* some wording around exception-catching

* Tidy test

* Simplify

* Add tests

* Add more test coverage

* Apply suggestions from code review

Co-authored-by: Travis Ralston <travpc@gmail.com>

* Update crypto.spec.js

* Update spec/unit/crypto.spec.js

Co-authored-by: Faye Duxovni <duxovni@duxovni.org>

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Travis Ralston <travpc@gmail.com>
Co-authored-by: Faye Duxovni <duxovni@duxovni.org>
2022-06-13 19:05:03 +00:00
Michael Telatynski 4897bccdc9 Improve decryption failure logging (#2453)
* Improve typing

* Log the actual errors to include call stacks
2022-06-13 13:26:01 +01:00
Michael Telatynski aaf508e309 Update sonarcloud.yml (#2452) 2022-06-11 21:02:30 +00:00
Michael Telatynski 8eeefc72e9 Update pull_request.yaml (#2449) 2022-06-10 17:29:48 +01:00
Michael Telatynski 8e896c4da3 Fix issues with getEventTimeline and thread roots (#2444)
* Add additional tests for thread timelines

* Fix issues around mixing up event timeline sets with /context/ API

* Increase coverage

* Increase coverage

* Better scope assertions

* Iterate PR
2022-06-08 22:11:22 +00:00
Kerry 2c2686c910 Test typescriptification - cross-signing.spec (#2441)
* enamed:    spec/unit/crypto/secrets.spec.js -> spec/unit/crypto/secrets.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in secrets.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* renamed:    spec/unit/crypto/outgoing-room-key-requests.spec.js -> spec/unit/crypto/outgoing-room-key-requests.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in outgoing-room-key-requests.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* renamed:    spec/unit/crypto/DeviceList.spec.js -> spec/unit/crypto/DeviceList.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in DeviceList.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* renamed:    spec/unit/crypto/CrossSigningInfo.spec.js -> spec/unit/crypto/CrossSigningInfo.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in CrossSigningInfo.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* renamed:    spec/unit/crypto/cross-signing.spec.js -> spec/unit/crypto/cross-signing.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix most function call types in cross-signing.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* typed events in cross-signing

Signed-off-by: Kerry Archibald <kerrya@element.io>

* type cross signing keys

Signed-off-by: Kerry Archibald <kerrya@element.io>

* convince the rest of the key types in cross-signing.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* use correct type for IDevice
2022-06-08 13:03:02 +00:00
Michael Telatynski b20063f8a8 Pass missing ci secret (#2442)
* Update pull_request.yaml

* Update pull_request.yaml
2022-06-08 13:07:38 +01:00
Michael Telatynski 11cc2aca9d Add CI to improve experience for community (#2439)
* Add CI to improve experience for community

* Fix close-if-fork-develop if-condition

* Extract into reusable workflow

* Update pull_request.yaml
2022-06-08 10:43:36 +00:00
Kerry 8f5162c40d Test typescriptification - crypto unit tests pt 1 (#2440)
* enamed:    spec/unit/crypto/secrets.spec.js -> spec/unit/crypto/secrets.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in secrets.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* renamed:    spec/unit/crypto/outgoing-room-key-requests.spec.js -> spec/unit/crypto/outgoing-room-key-requests.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in outgoing-room-key-requests.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* renamed:    spec/unit/crypto/DeviceList.spec.js -> spec/unit/crypto/DeviceList.spec.ts

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix ts issues in DeviceList.spec

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-06-08 12:39:08 +02:00
Kerry 2982bd79f6 Live location sharing - monitor liveness of beacons yet to start (PSF-1081) (#2437)
* monitor liveness of beacons yet to start

* make watch interval a timeout instead
2022-06-07 17:04:58 +02:00
Eric Eastwood 96c35e2dd3 Add more detail on the context/rationale that should be included when contributing (#2432)
Follow-up to https://github.com/matrix-org/matrix-js-sdk/pull/1933

Spawning from various recent documents and comments:

 - https://github.com/vector-im/element-meta/wiki/Review-process
 - https://github.com/matrix-org/synapse/pull/12846#discussion_r887270734
 - https://gitlab.matrix.org/new-vector/internal/-/wikis/Backend/Reviews
2022-06-07 08:52:08 -05:00
RiotRobot cb5b2e1470 Resetting package fields for development 2022-06-07 12:09:10 +01:00
RiotRobot 37d5e4a5e9 Merge branch 'master' into develop 2022-06-07 12:09:09 +01:00
RiotRobot bf30c15d77 v18.1.0 2022-06-07 12:06:42 +01:00
RiotRobot 8acc92770d Prepare changelog for v18.1.0 2022-06-07 12:06:42 +01:00
Michael Telatynski bfed6edf41 Refactor Relations to not be per-EventTimelineSet (#2412)
* Refactor Relations to not be per-EventTimelineSet

* Fix comment and relations-container init

* Revert timing tweaks

* Fix relations order test

* Add test and simplify thread relations handling

* Fix order of initialising a room object

* Fix test

* Re-add thread handling for relations of unloaded threads

* Ditch confusing experimental getter `MatrixEvent::isThreadRelation`

* Fix room handling in RelationsContainer

* Iterate PR

* Tweak method naming to closer match spec
2022-06-07 11:16:53 +01:00
Michael Telatynski 07189f0637 Add tests for sendEvent threadId handling (#2435)
* Add tests for sendEvent threadId handling

* Fix sendEvent threadId relation support not adding `is_falling_back` field
2022-06-07 09:13:01 +00:00
Jonathan de Jong aa94d5d95c Assume per-user deviceID uniqueness in encryptAndSendKeysToDevices (#2136)
* Segment recorded device info by user ID when tracking key shares.

Fixes #2135.

* address review feedback

* fix userIdDeviceInfo

Co-authored-by: Denis Kasak <dkasak@termina.org.uk>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-06-06 15:09:32 +01:00
Michael Telatynski d73126ecb2 Document how to inhibit code coverage requirement (#2436)
on specific sections
2022-06-06 13:13:16 +00:00
Michael Telatynski 76797704ea Move pr_details and sonarqube to released composite actions (#2425)
* Move pr_details and sonarqube to released composite actions

* Modify correct file

* Bring back a reusable workflow for element-web stack sonarqube runs

* Move sonarcloud.yml to the right repo

* Update to matrix-org/sonarcloud-workflow-action@v2.1
2022-06-06 11:37:49 +01:00
Faye Duxovni e35ede0370 The request callback provided by bootstrapCrossSigning is async (#2431) 2022-06-03 08:58:14 -04:00
Kerry 518e16e6d5 matrix-mock-request to 2.0.1 (#2416)
* matrix-mock-request to 2.0.0

Signed-off-by: Kerry Archibald <kerrya@element.io>

* track and destroy timeouts from test client

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove debug

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix bad property refernce caught by ts TestClient

Signed-off-by: Kerry Archibald <kerrya@element.io>

* Revert "fix bad property refernce caught by ts TestClient"

This reverts commit 92c9f6cb1308fe1afdf0655babcb886acebf05ca.

* update yarn lock

Signed-off-by: Kerry Archibald <kerrya@element.io>

* correct IUploadKeysRequest type

* fix types in TestClient for typed matrix-mock-request

Signed-off-by: Kerry Archibald <kerrya@element.io>

* update to matrix-mock-request 2.0.1

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-06-03 08:35:26 +00:00
Šimon Brandner 012f6c56e6 Update MSC3786 implementation: Check the state_key (#2429) 2022-06-03 06:06:48 +02:00
Faye Duxovni 8711499121 Don't bug the user while re-checking key backups after decryption failures (#2430) 2022-06-02 13:28:08 -04:00
Eric Eastwood b64dbdce74 Timeline needs to refresh when we see a MSC2716 marker event (#2299)
Inform the client that historical messages were imported in the timeline and they should refresh the timeline in order to see the new events.

Companion `matrix-react-sdk` PR: https://github.com/matrix-org/matrix-react-sdk/pull/8354

The `marker` events are being used as state now because this way they can't be lost in a timeline gap. Regardless of when they were sent, we will still have the latest version of the state to compare against. Any time we see our latest state value change for marker events, prompt the user that the timeline needs to refresh.

> In a [sync meeting with @ara4n](https://docs.google.com/document/d/1KCEmpnGr4J-I8EeaVQ8QJZKBDu53ViI7V62y5BzfXr0/edit#bookmark=id.67nio1ka8znc), we came up with the idea to make the `marker` events as state events. When the client sees that the `m.room.marker` state changed to a different event ID, it can throw away all of the timeline and re-fetch as needed.
>
> For homeservers where the [same problem](https://github.com/matrix-org/matrix-doc/pull/2716#discussion_r782499674) can happen, we probably don't want to throw away the whole timeline but it can go up the `unsigned.replaces_state` chain of the `m.room.marker` state events to get them all.
>
> In terms of state performance, there could be thousands of `marker` events in a room but it's no different than room members joining and leaving over and over like an IRC room.
>
> *-- https://github.com/matrix-org/matrix-spec-proposals/pull/2716#discussion_r782629097*


### Why are we just setting `timlineNeedsRefresh` (and [prompting the user](https://github.com/matrix-org/matrix-react-sdk/pull/8354)) instead of automatically refreshing the timeline for the user?

If we refreshed the timeline automatically, someone could cause your Element client to constantly refresh the timeline by just sending marker events over and over. Granted, you probably want to leave a room like this 🤷. Perhaps also some sort of DOS vector since everyone will be refreshing and hitting the server at the exact same time.

In order to avoid the timeline maybe going blank during the refresh, we could re-fetch the new events first, then replace the timeline. But the points above still stand on why we shouldn't.
2022-06-01 16:31:20 -05:00
Michael Telatynski 2e27a4134c Fix test suite regression due to TestClient refactoring (#2426) 2022-06-01 08:37:44 +00:00
Faye Duxovni 8412ccfa9b Try to load keys from key backup when a message fails to decrypt (#2373)
Co-authored-by: Travis Ralston <travisr@matrix.org>
2022-06-01 00:43:23 -04:00
RiotRobot c707c8632b v18.1.0-rc.1 2022-05-31 11:28:08 +01:00
RiotRobot 2700f1d053 Prepare changelog for v18.1.0-rc.1 2022-05-31 11:28:07 +01:00
renovate[bot] 142c285063 Lock file maintenance (#2413)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-31 09:52:52 +00:00
Michael Telatynski c0ff9756a8 Update renovate.json (#2419) 2022-05-31 10:43:46 +01:00
Michael Telatynski 64c6c477dc Revert "Fix request, crypto, and bs58 imports (#2414)" (#2422)
This reverts commit 05dd3c4967.
2022-05-30 15:46:59 +01:00
Michael Telatynski c28939c250 Revert "Github Actions pull_request synchronize runs on PR open anyway" (#2420)
* Revert "Github Actions pull_request synchronize runs on PR open anyway (#2418)"

This reverts commit 8c72c5d0e6.

* Update pull_request.yaml
2022-05-30 15:40:50 +01:00
Michael Telatynski 05dd3c4967 Fix request, crypto, and bs58 imports (#2414) 2022-05-30 13:49:25 +00:00
Michael Telatynski 8c72c5d0e6 Github Actions pull_request synchronize runs on PR open anyway (#2418) 2022-05-30 14:28:40 +01:00
renovate[bot] 93293750ce Update babel monorepo (#2409)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-27 16:38:57 +00:00
renovate[bot] 220f709b3a Update typescript-eslint monorepo to v5.26.0 (#2410)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-27 16:32:18 +00:00
Michael Telatynski 6c336ae470 Update sonarcloud.yml (#2411) 2022-05-27 17:28:37 +01:00
renovate[bot] a4a50a4a5c Update jest monorepo (major) (#2407)
* Update jest monorepo

* -w

* Fix guest rooms test to use async/await instead of a done callback

The done callback was never being called because it relies on a `process.nextTick()` deep within the mock. For this test we don't get a "next tick" because the event loop is busy, so we instead cargocult some test infrastructure from surrounding tests and verify the expected API call was cleared from the queue.

* Enable github-actions reporter

* Don't override local reporters

* Stop DeviceLists at end of tests

* stop more clients

* Fix tests and DRY typing

* Fix client/crypto stopping in tests

* Fix Buffer c'tor deprecated warnings

* Fix devicelist-integ test being excluded due to poor naming

Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Travis Ralston <travisr@matrix.org>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-05-27 16:16:00 +01:00
Michael Telatynski 169e865bb6 Fix sonarqube using base branch on fork for detecting new code in pr (#2394)
* Fix sonarqube using base branch on fork for detecting new code in pr

* Add comment

* Tweak comment

* Fix origin vs upstream

* Stop wrongly using github.action_repository

* Fix condition, we can add upstream always
2022-05-27 14:10:15 +01:00
renovate[bot] 8803d2b7e2 Update all (#2408)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-05-27 12:50:51 +00:00
renovate[bot] ab16adfb7d Configure Renovate (#2405)
* Add renovate.json

* Update renovate.json

* Update renovate.json

* Update renovate.json

* Update renovate.json

Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-05-26 20:52:40 +01:00
Michael Telatynski f90adcab89 Fix gha concurrency conditions (#2404) 2022-05-26 09:22:49 +00:00
Michael Telatynski 6090c7bbfc This file is no longer referenced (#2403) 2022-05-26 10:21:27 +01:00
Travis Ralston 12253064d1 Convert getLocalAliases to a stable API call (#2402)
* Convert getLocalAliases to a stable API call

* Appease the linter
2022-05-25 15:56:27 -06:00
Michael Telatynski b2120a0a13 Initial attempt at automating jsdoc (#2382)
* Initial attempt at automating jsdoc

* Commit tested jsdoc workflow
2022-05-25 21:52:24 +01:00
Michael Weimann ad030bfc1f Update relations after every decryption attempt (#2387)
* Update relations after every decryption attempt

If an event is encrypted the aggregation cannot pick up the relation types.
Before this change there was exactly one aggregation retry after decryption.
If the events are being decrypted afterwards (for example on restore
from key backup) the aggregation was not aware of that.
This change adds relation updates after every decryption event if there
has been a decryption error.

Signed-off-by: Michael Weimann <michaelw@matrix.org>
2022-05-25 08:39:18 +02:00
Michael Telatynski 60d665e866 Fix degraded mode for the IDBStore and test it (#2400)
* Add tests around IDB degraded mode

* Fix wrong `this` reference in idb degraded mode store
2022-05-25 07:02:14 +01:00
Michael Telatynski 2fd48a607d Improve PR Details job to use github-script and output labels (#2397)
* Improve PR Details job to use github-script and output labels

* Fix wrongly using github.ref in workflow_run actions which always refer to develop

* Update pr-details to be far more generic
2022-05-24 19:16:08 +01:00
RiotRobot 2f540e31a5 Resetting package fields for development 2022-05-24 12:40:35 +01:00
RiotRobot 7bd9626496 Merge branch 'master' into develop 2022-05-24 12:40:34 +01:00
RiotRobot 58a5742bd3 v18.0.0 2022-05-24 12:36:49 +01:00
RiotRobot d90f983438 Prepare changelog for v18.0.0 2022-05-24 12:36:48 +01:00
Michael Telatynski 81a6e48fc0 Fix http-api MatrixError httpStatus vs http_status (#2396) 2022-05-24 09:05:27 +01:00
Matthew Hodgson db4a6fa9e1 Merge pull request #2250 from matrix-org/matthew/fix-flaky-verif-test
Don't cancel SAS verifications if `ready` is received after `start`
2022-05-23 20:59:39 +01:00
Matthew Hodgson 457f063c67 Merge branch 'develop' into matthew/fix-flaky-verif-test 2022-05-23 20:53:59 +01:00
Šimon Brandner 01eee96b29 Remove dont_notify from the .m.rule.room.server_acl rule (#2395) 2022-05-23 20:45:52 +02:00
Matthew Hodgson 25afb7cb3c Merge pull request #2392 from matrix-org/matthew/fix-sync-acc-overlap
Prevent overlapping sync accumulator persists
2022-05-21 14:32:46 +01:00
Matthew Hodgson c12932b2a6 switch to imperative try...finally 2022-05-21 14:27:07 +01:00
Matthew Hodgson 7ac1d07cc4 don't underscore-prefix private fields 2022-05-21 13:15:52 +01:00
Matthew Hodgson 66adc2d597 prevent overlapping sync accumulator persists
add a flag to stop the sync worker trying to persist to indexeddb
if there are already persists in flight. accumulates user presence
updates in RAM to stop them being lost if the persist is skipped.

hopefully fixes https://github.com/vector-im/element-web/issues/21541
2022-05-21 12:47:59 +01:00
Janne Mareike Koschinski a9516d047f types: improve types for registration calls (#2390) 2022-05-20 16:34:28 +01:00
Michael Telatynski e81d84502b Fix behaviour of isRelation with relation m.replace for state events (#2389)
* Add some short-circuits to skip async code

* Fix behaviour of `isRelation` with relation `m.replace` for state events
2022-05-20 12:32:59 +01:00
RiotRobot 32b2c217c7 v18.0.0-rc.2 2022-05-20 10:03:29 +01:00
RiotRobot 7da555c255 Prepare changelog for v18.0.0-rc.2 2022-05-20 10:03:28 +01:00
Michael Telatynski 88348660eb Catch promise errors in degradable (fixes #2384) (#2385) (#2388)
Co-authored-by: Lars Richard <lars.richard@iserv.eu>
(cherry picked from commit 81d884f899)

Co-authored-by: schmop <lars.richard@rocketmail.com>
2022-05-20 09:56:05 +01:00
schmop 81d884f899 Catch promise errors in degradable (fixes #2384) (#2385)
Co-authored-by: Lars Richard <lars.richard@iserv.eu>
2022-05-19 12:56:25 +01:00
Travis Ralston 2dccefb33a Ensure rooms are recalculated on re-invites (#2374)
* Write the failing test

* Fix the bug

* Add known copyright
2022-05-18 04:37:43 +00:00
RiotRobot af6915b4fc Merge pull request #2381 from matrix-org/actions/upgrade-deps
Upgrade dependencies
2022-05-17 20:09:49 +01:00
t3chguy 3786ea4ca2 [create-pull-request] automated change 2022-05-17 19:03:30 +00:00
Michael Telatynski c7f6777e48 Update CONTRIBUTING.md (#2380) 2022-05-17 18:18:19 +00:00
RiotRobot ba371f7468 v18.0.0-rc.1 2022-05-17 18:26:33 +01:00
RiotRobot 761facf98a Prepare changelog for v18.0.0-rc.1 2022-05-17 18:26:32 +01:00
Michael Telatynski f78fed5ede Revert "Sonarcloud check out upstream develop not fork develop (#2378)" (#2379)
This reverts commit add3732450.
2022-05-17 18:20:42 +01:00
Michael Telatynski add3732450 Sonarcloud check out upstream develop not fork develop (#2378) 2022-05-17 18:09:00 +01:00
Travis Ralston c6af997542 Add a catastrophic throw to thread constructor (#2375)
This is an attempt to narrow down https://github.com/vector-im/element-web/issues/22141
2022-05-17 00:20:41 -06:00
Travis Ralston e9e8e90a94 Remove default push rule override for MSC1930 (#2376)
Folks have had since Matrix 1.0 (June 2019) to upgrade to a compatible server
2022-05-17 00:13:43 -06:00
Johannes Marbach f44510e65f Add support for HTML renderings of room topics (#2272)
* Add support for HTML renderings of room topics

Based on extensible events as defined in [MSC1767]

Relates to: vector-im/element-web#5180
Signed-off-by: Johannes Marbach <johannesm@element.io>

[MSC1767]: https://github.com/matrix-org/matrix-spec-proposals/pull/1767

* Use correct MSC

* Add overloads for setRoomTopic

* Fix indentation

* Add more tests to pass the quality gate

Co-authored-by: Johannes Marbach <jm@Johanness-Mini.fritz.box>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-05-16 10:37:34 +01:00
Michael Telatynski ba1f6ffc84 Tweak thread creation & event adding to fix bugs around relations (#2369)
* Remove legacy code which caused threads to begin life with too many events

* Update tests & behaviour
2022-05-16 09:01:39 +01:00
Michael Telatynski 3e4f02b41e Update notify-downstream.yaml (#2371) 2022-05-14 01:27:54 -06:00
Michael Telatynski af17fb27b8 Attempt to re-structure workflows to be more generic & reusable (#2364)
* Attempt to re-structure workflows to be more generic & reusable

* Iterate for reusable workflows can't call each other

* don't pass pullrequest params if no prnumber

* Comments

* Fix reusable workflow call

* Pass pr_id properly

* Fix run condition for prdetails job

* Fix needs dependency

* Stash work so far

* Fix copypasta

* Update

* Define outputs from pr_details.yml

* Fix output reporting

* Fix something or other
2022-05-13 23:14:46 +01:00
Michael Telatynski 72013341db More sonar tweaks and typing improvements (#2366)
* More sonar tweaks and typing improvements

* delint

* Write some tests

* Attempt to make TS happy

* Stash tests

* Add tests

* Add `istanbul ignore if` around logging special-case for test env

* Add test

* Comments
2022-05-13 18:08:36 +00:00
Michael Telatynski 6f445ca99a Add stopClient parameter to MatrixClient::logout (#2367)
* Add stopClient parameter to MatrixClient::logout

* Add short-circuit
2022-05-13 19:00:06 +01:00
Michael Telatynski e2af78d8d3 Make pull_request.yaml between the layers consistent and fix enforce labels (#2368) 2022-05-13 18:51:48 +01:00
Michael Telatynski 4721aa1d24 Fix up more types & Sonar warnings (#2363)
* Fix up more types & Sonar warnings

* Fix test

* Add first test for callEventHandler
2022-05-12 10:12:39 +01:00
Michael Telatynski 4d4d6e1411 NodeURL isn't needed as Node exports the standard URL c'tor as global (#2361)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2022-05-11 17:04:10 +01:00
Michael Telatynski 67f5293d6c Tweaks for sonar to correctly report on forked PRs (#2359) 2022-05-11 16:42:25 +01:00
Janne Mareike Koschinski 923ff4b282 registration: add function to re-request email token (#2357) 2022-05-11 13:17:36 +02:00
Michael Telatynski 49dd76b91e Remove redundant checkKey param on isSecretStored (#2360) 2022-05-11 10:53:52 +01:00
Michael Telatynski 34cfa51104 Pass more args to Sonar (#2358)
* Pass projectVersion to Sonar

* Fix s'more

* Fix sonar.lang.patterns.ts

* Apply more tweaks

Based on https://community.sonarsource.com/t/how-to-use-sonarcloud-with-a-forked-repository-on-github/7363/28
2022-05-10 17:11:07 +01:00
Michael Telatynski 685276f056 Pass more args to Sonar (#2356)
* Pass projectVersion to Sonar

* Fix s'more

* Fix sonar.lang.patterns.ts
2022-05-10 16:49:03 +01:00
RiotRobot 03a79dc6dd Resetting package fields for development 2022-05-10 14:50:07 +01:00
RiotRobot 83a4881498 Merge branch 'master' into develop 2022-05-10 14:50:07 +01:00
RiotRobot e29ee105aa v17.2.0 2022-05-10 14:46:15 +01:00
RiotRobot 454eb7e627 Prepare changelog for v17.2.0 2022-05-10 14:46:15 +01:00
Travis Ralston 62d77231af Remove spec v1.3 check for threads (#2354)
* Remove spec v1.3 check for threads

Citation: https://matrix.to/#/!ewdjhNcPcEmYNKzlWp:t2l.io/$CkPuvKdFZyFL547JCy5J3MfvLaWUo_a1XEdmiop1PKc?via=matrix.org&via=element.io&via=envs.net

* Enable stable support always for threads

* Fix tests differently
2022-05-09 16:11:04 -06:00
Michael Telatynski 706b4d6054 Improve typing (#2352)
* Fix typing of the store interface

* Fix typed s'more

* re-add check

* Be less dumb

* arg

* Fix types
2022-05-09 11:58:52 +01:00
Šimon Brandner da69ca215b Implement changes to MSC2285 (private read receipts) (#2221)
* Add `ReceiptType`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Implement changes to MSC2285

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Improve tests

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Apply suggestions from review

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Update `getEventReadUpTo()` to handle private read receipts

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Write tests for `getEventReadUpTo()`

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Give `getReadReceiptForUserId()` a JSDOC

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Types!

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Try to use receipt `ts`s

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-05-06 21:32:41 +02:00
Travis Ralston 95a94cdbe3 Remove hacky custom status feature (#2350)
This is unstable, so should be more than safe to just outright remove without notice.
2022-05-06 13:20:54 -06:00
Michael Telatynski 6b5f4aa0a9 Prune both clear & wire content on redaction (#2346) 2022-05-05 07:14:23 +01:00
Michael Telatynski dea3f52fe9 Another SonarQube happiness pass (#2347) 2022-05-04 21:34:21 -06:00
Michael Telatynski a388fde3e2 Tweak sonar-project.properties (#2348) 2022-05-04 15:36:00 -04:00
Šimon Brandner 1cde686a13 MSC3786: Add a default push rule to ignore m.room.server_acl events (#2333)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-05-04 16:22:43 +02:00
Michael Telatynski 592f2931dc Add label to dependency upgrade PRs (#2345) 2022-05-04 14:27:42 +01:00
Michael Telatynski 030fcb57a5 Update upgrade_dependencies.yml (#2343) 2022-05-03 22:44:57 +01:00
Michael Telatynski 8be30acb11 Apply suggestions from SonarQube (#2340) 2022-05-03 15:40:57 -06:00
github-actions[bot] b86630f0e3 Upgrade dependencies (#2342)
Co-authored-by: t3chguy <t3chguy@users.noreply.github.com>
2022-05-03 22:10:41 +01:00
Michael Telatynski 81c89f69c5 Create manual action for upgrading dependencies after rc cut (#2341) 2022-05-03 21:54:18 +01:00
Michael Telatynski 9b633251d5 Add README badges (#2335) 2022-05-03 14:52:44 -06:00
RiotRobot 70c6a4b567 v17.2.0-rc.1 2022-05-03 15:20:39 +01:00
RiotRobot 5cd615c495 Prepare changelog for v17.2.0-rc.1 2022-05-03 15:20:38 +01:00
Michael Telatynski 88f5ea675d Tweak sonarqube run (#2339) 2022-05-03 14:46:36 +01:00
Michael Telatynski ac5fee0a69 Fix race conditions around threads (#2331) 2022-05-03 14:25:17 +01:00
Michael Telatynski 274d6a9597 Add CI to require labels on PRs for better changelog management (#2337) 2022-05-03 10:34:55 +01:00
Michael Telatynski d190cdc307 Switch coverage to SonarQube (#2334) 2022-05-02 08:32:53 +00:00
Michael Telatynski b896111269 Apply corrections identified by SonarQube (#2336)
* Apply corrections identified by SonarQube

* Apply corrections identified by SonarQube

* Make type more flexible
2022-05-02 03:23:17 +01:00
Aaron Raimist 6137afeb28 Mark room versions 7, 8, and 9 as safe (#2318)
* Mark room versions 7, 8, and 9 as safe

Workaround for https://github.com/vector-im/element-web/issues/20166 https://github.com/vector-im/element-web/issues/11820

* Update KNOWN_SAFE_ROOM_VERSION to room version 9
2022-05-01 19:10:09 -06:00
Michael Telatynski 2ebf33544f Try SonarCloud (#2330) 2022-04-29 11:07:35 +01:00
Michael Telatynski 45f1991d4e Correctly specify minimum Node version (#2324) 2022-04-29 11:02:05 +01:00
Michael Telatynski aa283a3c9e Update CONTRIBUTING.md (#2329) 2022-04-28 15:48:21 +01:00
Kerry 34ee566d88 Live location sharing: handle encrypted messages in processBeaconEvents (#2327)
* handle encrypted locations

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix processBeaconEvents to handle encrypted events

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-28 16:42:37 +02:00
RiotRobot 3649cf46d3 Resetting package fields for development 2022-04-26 11:34:42 +01:00
RiotRobot 8f00eb6771 Merge branch 'master' into develop 2022-04-26 11:34:42 +01:00
Michael Telatynski 01ec51891a Merge remote-tracking branch 'origin/release-v17.1.0'
# Conflicts:
#	CHANGELOG.md
#	package.json
#	src/client.ts
#	src/models/room.ts
#	src/models/thread.ts
#	src/sync.ts
2022-04-26 11:31:20 +01:00
RiotRobot 738876a563 v17.1.0 2022-04-26 11:13:47 +01:00
RiotRobot 2d9b4b3896 Prepare changelog for v17.1.0 2022-04-26 11:13:46 +01:00
Michael Telatynski ba06e430c4 Ignore m.replace relations on state events, they're invalid (#2306)
* Ignore m.replace relations on state events, they're invalid

* Add tests

* Fix test

Co-authored-by: Kerry <kerrya@element.io>
2022-04-26 10:02:36 +00:00
Michael Telatynski ac08e52410 De-duplicate code and fix types (#2319) 2022-04-25 17:15:35 +01:00
David Baker 1bb82108b7 fix example in readme (#2315)
Co-authored-by: Matthew Hodgson <matthew@matrix.org>
2022-04-22 12:04:31 +01:00
Michael Telatynski e133005b44 Don't decrement the length count of a thread when root redacted (#2314) 2022-04-21 16:02:42 +00:00
Michael Telatynski c0cb66233a Prevent attempt to create thread with id "undefined" (#2308) 2022-04-21 08:01:24 +01:00
Michael Telatynski d82cdd3b19 Move more stuff from BK to GHA (#2309) 2022-04-20 18:09:33 +01:00
Michael Telatynski 6a51b02bed Move more stuff from BK to GHA (#2307)
* Use consistent indentation in GHA yaml files

* Prefer setup-node's dep caching

* Tidy up test_coverage job

* Move js sdk lint ci to gha

* notify react-sdk of develop merges

* Name the jobs

* test

* Update secrets

* Fixup
2022-04-20 16:25:58 +01:00
Michael Telatynski 47d2c063fa Upgrade dependencies (#2304) 2022-04-20 13:53:39 +02:00
Michael Telatynski 540514c805 Update threads handling for replies-to-thread-responses as per MSC update (#2305)
* Update threads handling for replies-to-thread-responses as per MSC update

* Update tests to match new behaviour
2022-04-19 18:15:06 +02:00
Faye Duxovni db58a66e19 Add method for checking whether our other devices are cross-signed, even when this device isn't (#2288) 2022-04-19 15:02:17 +01:00
RiotRobot 91a67bdac3 v17.1.0-rc.1 2022-04-19 14:41:27 +01:00
RiotRobot 48d3fce22d Prepare changelog for v17.1.0-rc.1 2022-04-19 14:41:26 +01:00
Hugh Nimmo-Smith 9f4598638d Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 (#2297) 2022-04-15 10:27:12 +01:00
CommanderRoot cde935629d Replace deprecated String.prototype.substr() (#2298)
.substr() is deprecated so we replace it with .slice() which works similarily but isn't deprecated

Signed-off-by: Tobias Speicher <rootcommander@gmail.com>
2022-04-14 15:23:27 -06:00
Kerry fbe81ad823 Live location sharing - expose room liveBeaconIds (#2296)
* updates rooms live beacon ids on destroy

Signed-off-by: Kerry Archibald <kerrya@element.io>

* expose live beacons ids

Signed-off-by: Kerry Archibald <kerrya@element.io>

* room state emit all the time on beacon liveness change

Signed-off-by: Kerry Archibald <kerrya@element.io>

* update comment

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-14 15:04:43 +02:00
Michael Telatynski c3d7a4977a Stop tracking threads if threads support is disabled (#2295) 2022-04-13 16:23:15 +01:00
Robin 9aab917836 Fix coverage diffs for PRs that aren't up to date, take 3 (#2294) 2022-04-12 20:22:14 -04:00
Jonathan de Jong 54661cca95 Change _unstable_getSharedRooms to _unstable_getMutualRooms (#2271)
* change shared to mutual

* revert name change

* use new unstable feature flag

* support both unstable endpoints
2022-04-12 17:48:48 -06:00
Michael Telatynski b58d09aa9a Prevent duplicated re-emitter setups in event-mapper (#2293) 2022-04-12 09:42:55 +01:00
RiotRobot 3b33237e51 Resetting package fields for development 2022-04-11 16:31:44 +01:00
RiotRobot b8ec62e786 Merge master 2022-04-11 16:31:21 +01:00
Michael Telatynski 286500e335 Fix issues around echo & redaction handling in threads (#2286) 2022-04-11 08:58:13 +01:00
Hugh Nimmo-Smith 5937e6a6a8 Support for MSC2457 logout_devices param for setPassword() (#2285) 2022-04-09 14:07:33 +01:00
Eric Eastwood c6c22e394b Ignore eslint --fix formatting changes in git blame (#2287)
* Ignore eslint --fix formatting changes in git blame

Docs: https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view

* Conform on white-space spelling
2022-04-08 23:52:05 -06:00
Callum Brown 378802a5ab Stabilise token authenticated registration support (#2181)
* Stabilise token authenticated registration support

Token authenticated registration was added to the Matrix specification in v1.2:
https://spec.matrix.org/v1.2/client-server-api/#token-authenticated-registration

Signed-off-by: Callum Brown <callum@calcuode.com>

* Backwards compatibility with unstable auth type

Servers are not yet widely updated with support for the stable
version of the registration token UIA type.
Clients should check if the authentication type is either
`RegistrationToken` or `UnstableRegistrationToken`.

Signed-off-by: Callum Brown <callum@calcuode.com>
2022-04-08 22:53:57 -06:00
Kerry f963feab0f Live location sharing - Aggregate beacon locations on beacons (#2268)
* add timestamp sorting util

Signed-off-by: Kerry Archibald <kerrya@element.io>

* basic wiring

Signed-off-by: Kerry Archibald <kerrya@element.io>

* quick handle for redacted beacons

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove fdescribe

Signed-off-by: Kerry Archibald <kerrya@element.io>

* test adding locations

Signed-off-by: Kerry Archibald <kerrya@element.io>

* tidy comments

Signed-off-by: Kerry Archibald <kerrya@element.io>

* test client

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix monitorLiveness for update

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-08 12:26:05 +01:00
Germain 6d0f4e537e Fix notification panel not loading (#2283) 2022-04-08 10:34:01 +01:00
Kerry 781fdf4fdc Live location sharing - update beacon_info implementation to latest MSC (#2281)
* remove M_BEACON_INFO_VARIABLE

Signed-off-by: Kerry Archibald <kerrya@element.io>

* create beacon_info events with non-variable event type

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove isBeaconInfoEventType

Signed-off-by: Kerry Archibald <kerrya@element.io>

* refer to msc3673 instead of msc3489

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove event type suffix

Signed-off-by: Kerry Archibald <kerrya@element.io>

* update beacon identifier to use state key

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix beacon spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix room-state tests

Signed-off-by: Kerry Archibald <kerrya@element.io>

* add beacon identifier

Signed-off-by: Kerry Archibald <kerrya@element.io>

* dont allow update to older beacon event

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>

* unnest beacon_info content

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>

* check redaction event id

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-08 10:50:06 +02:00
Michael Telatynski dde4285cdf Fix handling of threaded messages around edits & echoes (#2267) 2022-04-07 13:46:50 +01:00
Robin 3322b47b6d Make self membership less prone to races (#2277) 2022-04-06 08:49:09 -04:00
Matthew Hodgson d9f0704048 reduce flakiness of e2e verif test
it's completely valid to receive a `ready` event after having received a
`start` event as messages may be received or decrypted in any order.

partial (but possibly sufficient?) fix for https://github.com/vector-im/element-web/issues/21488
2022-03-20 20:11:15 +00:00
165 changed files with 18624 additions and 10093 deletions
+3
View File
@@ -21,3 +21,6 @@ insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_size = 2
+4
View File
@@ -9,6 +9,8 @@ module.exports = {
browser: true,
node: true,
},
// NOTE: These rules are frozen and new rules should not be added here.
// New changes belong in https://github.com/matrix-org/eslint-plugin-matrix-org/
rules: {
"no-var": ["warn"],
"prefer-rest-params": ["warn"],
@@ -52,6 +54,8 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "off",
// We'd rather not do this but we do
"@typescript-eslint/ban-ts-comment": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
"quotes": "off",
// We use a `logger` intermediary module
+41
View File
@@ -0,0 +1,41 @@
# Minor white-space adjustments
1d1d59c75744e1f6a2be1cb3e0d1bd9ded5f8025
# Import ordering and spacing: eslint-plugin-import
80aaa6c32b50601f82e0c991c24e5a4590f39463
# Minor white-space adjustment
8fb036ba2d01fab66dc4373802ccf19b5cac8541
# Minor white-space adjustment
b63de6a902a9e1f8ffd7697dea33820fc04f028e
3ca84cfc491b0987eec1f13f13cae58d2032bf54
# Conform to new typescript eslint rules
a87858840b57514603f63e2abbbda4f107f05a77
5cf6684129a921295f5593173f16f192336fe0a2
# Comply with new member-delimiter-style rule
b2ad957d298720d3e026b6bd91be0c403338361a
# Fix semicolons in TS files
e2ec8952e38b8fea3f0ccaa09ecb42feeba0d923
# Migrate to `eslint-plugin-matrix-org`
# and `babel/...` to `@babel/...` migration
09fac77ce0d9bcf6637088c29afab84084f0e739
102704e91a70643bcc09721e14b0d909f0ef55c6
# Eslint formatting
cec00cd303787fa9008b6c48826e75ed438036fa
# Minor eslint changes
68bb8182e4e62d8f450f80c408c4b231b8725f1b
c979ff6696e30ab8983ac416a3590996d84d3560
f4a7395e3a3751a1a8e92dd302c49175a3296ad2
# eslint --fix for dangley commas on function calls
423175f5397910b0afe3112d6fb18283fc7d27d4
# eslint ---fix for prefer-const
7bca05af644e8b997dae81e568a3913d8f18d7ca
# Fix linting on tests
cee7f7a280a8c20bafc21c0a2911f60851f7a7ca
# eslint --fix
0fa9f7c6098822db1ae214f352fd1fe5c248b02c
# eslint --fix for lots of white-space
5abf6b9f208801c5022a47023150b5846cb0b309
# eslint --fix
7ed65407e6cdf292ce3cf659310c68d19dcd52b2
# Switch to ESLint from JSHint (Google eslint rules as a base)
e057956ede9ad1a931ff8050c411aca7907e0394
+10 -4
View File
@@ -1,7 +1,13 @@
<!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md before submitting your pull request -->
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
<!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md#sign-off -->
## Checklist
<!-- To specify text for the changelog entry (otherwise the PR title will be used):
Notes:
* [ ] Tests written for new code (and old code if feasible)
* [ ] Linter and other CI checks pass
* [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md))
<!--
If you would like to specify text for the changelog entry other than your PR title, add the following:
Notes: Add super cool feature
-->
-12
View File
@@ -1,12 +0,0 @@
codecov:
allow_coverage_offsets: True
coverage:
status:
project: off
patch: off
comment:
layout: "diff, files"
behavior: default
require_changes: false
require_base: no
require_head: no
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>matrix-org/renovate-config-element-web"
]
}
+53
View File
@@ -0,0 +1,53 @@
name: Release Process
on:
release:
types: [ published ]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
jsdoc:
name: Publish Documentation
runs-on: ubuntu-latest
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v3
- name: 🔧 Yarn cache
uses: actions/setup-node@v3
with:
cache: "yarn"
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
- name: 📖 Generate JSDoc
run: "yarn gendoc"
- name: 📋 Copy to temp
run: |
ls -lah
tag="${{ github.ref_name }}"
version="${tag#v}"
echo "VERSION=$version" >> $GITHUB_ENV
cp -a "./.jsdoc/matrix-js-sdk/$version" $RUNNER_TEMP
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
- name: 🔪 Prepare
run: |
cp -a "$RUNNER_TEMP/$VERSION" .
# Add the new directory to the index if it isn't there already
if ! grep -q "Version $VERSION" index.html; then
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
fi
- name: 🚀 Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
publish_dir: .
+27
View File
@@ -0,0 +1,27 @@
name: Notify Downstream Projects
on:
push:
branches: [ develop ]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
notify-downstream:
# Only respect triggers from our develop branch, ignore that of forks
if: github.repository == 'matrix-org/matrix-js-sdk'
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- repo: vector-im/element-web
event: element-web-notify
- repo: matrix-org/matrix-react-sdk
event: upstream-sdk-notify
runs-on: ubuntu-latest
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
event-type: ${{ matrix.event }}
-12
View File
@@ -1,12 +0,0 @@
name: Preview Changelog
on:
pull_request_target:
types: [ opened, edited, labeled ]
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- name: Preview Changelog
uses: matrix-org/allchange@main
with:
ghToken: ${{ secrets.GITHUB_TOKEN }}
+91
View File
@@ -0,0 +1,91 @@
name: Pull Request
on:
pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call:
inputs:
labels:
type: string
default: "T-Defect,T-Deprecation,T-Enhancement,T-Task"
required: false
description: "No longer used, uses allchange logic now, will be removed at a later date"
secrets:
ELEMENT_BOT_TOKEN:
required: true
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
jobs:
changelog:
name: Preview Changelog
runs-on: ubuntu-latest
steps:
- uses: matrix-org/allchange@main
with:
ghToken: ${{ secrets.GITHUB_TOKEN }}
requireLabel: true
prevent-blocked:
name: Prevent Blocked
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@v6
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
core.setFailed("Preventing merge whilst PR is marked blocked!");
community-prs:
name: Label Community PRs
runs-on: ubuntu-latest
if: github.event.action == 'opened'
steps:
- name: Check membership
uses: tspascoal/get-user-teams-membership@v1
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
organization: matrix-org
team: Core Team
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Add label
if: ${{ steps.teams.outputs.isTeamMember == 'false' }}
uses: actions/github-script@v6
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['Z-Community-PR']
});
close-if-fork-develop:
name: Forbid develop branch fork contributions
runs-on: ubuntu-latest
if: >
github.event.action == 'opened' &&
github.event.pull_request.head.ref == 'develop' &&
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" +
" branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity." +
" See https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md",
});
github.rest.pulls.update({
pull_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed'
});
+24
View File
@@ -0,0 +1,24 @@
# Must only be called from a workflow_run in the context of the upstream repo
name: SonarCloud
on:
workflow_call:
secrets:
SONAR_TOKEN:
required: true
jobs:
sonarqube:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- name: "🩻 SonarCloud Scan"
uses: matrix-org/sonarcloud-workflow-action@v2.2
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
version_cmd: 'cat package.json | jq -r .version'
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
coverage_run_id: ${{ github.event.workflow_run.id }}
coverage_workflow_name: tests.yml
coverage_extract_path: coverage
+15
View File
@@ -0,0 +1,15 @@
name: SonarQube
on:
workflow_run:
workflows: [ "Tests" ]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
sonarqube:
name: 🩻 SonarQube
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+56
View File
@@ -0,0 +1,56 @@
name: Static Analysis
on:
pull_request: { }
push:
branches: [ develop, master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ts_lint:
name: "Typescript Syntax Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install Deps
run: "yarn install"
- name: Typecheck
run: "yarn run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install Deps
run: "yarn install"
- name: Run Linter
run: "yarn run lint:js"
docs:
name: "JSDoc Checker"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install Deps
run: "yarn install"
- name: Generate Docs
run: "yarn run gendoc"
-31
View File
@@ -1,31 +0,0 @@
name: Test coverage
on:
pull_request: {}
push:
branches: [develop, main, master]
jobs:
test-coverage:
runs-on: ubuntu-latest
env:
# This must be set for fetchdep.sh to get the right branch
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
# If this is a pull request, make sure we check out its head rather than the
# automatically generated merge commit, so that the coverage diff excludes
# unrelated changes in the base branch
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
- name: Yarn cache
uses: c-hive/gha-yarn-cache@v2
- name: Run tests with coverage
run: "yarn install && yarn build && yarn coverage"
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: false
verbose: true
+37
View File
@@ -0,0 +1,37 @@
name: Tests
on:
pull_request: { }
push:
branches: [ develop, master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
jest:
name: Jest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Yarn cache
uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install dependencies
run: "yarn install"
- name: Build
run: "yarn build"
- name: Run tests with coverage
run: "yarn coverage --ci --reporters github-actions"
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: coverage
path: |
coverage
!coverage/lcov-report
@@ -0,0 +1,38 @@
name: Upgrade Dependencies
on:
workflow_dispatch: { }
workflow_call:
secrets:
ELEMENT_BOT_TOKEN:
required: true
jobs:
upgrade:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Upgrade
run: yarn upgrade && yarn install
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/upgrade-deps
delete-branch: true
title: Upgrade dependencies
labels: |
Dependencies
T-Task
- name: Enable automerge
uses: peter-evans/enable-pull-request-automerge@v2
if: steps.cpr.outputs.pull-request-operation == 'created'
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
-2
View File
@@ -1,2 +0,0 @@
instrumentation:
compact: false
+138 -2
View File
@@ -1,3 +1,139 @@
Changes in [19.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.3.0-rc.1) (2022-08-09)
============================================================================================================
## ✨ Features
* Emit an event when the client receives TURN servers ([\#2529](https://github.com/matrix-org/matrix-js-sdk/pull/2529)).
* Add support for stable prefixes for MSC2285 ([\#2524](https://github.com/matrix-org/matrix-js-sdk/pull/2524)).
* Always block sending keys to unverified devices of verified users ([\#2562](https://github.com/matrix-org/matrix-js-sdk/pull/2562)). Contributed by @duxovni.
* Remove stream-replacement ([\#2551](https://github.com/matrix-org/matrix-js-sdk/pull/2551)).
* Add support for sending user-defined encrypted to-device messages ([\#2528](https://github.com/matrix-org/matrix-js-sdk/pull/2528)).
* Retry to-device messages ([\#2549](https://github.com/matrix-org/matrix-js-sdk/pull/2549)). Fixes vector-im/element-web#12851.
* Sliding sync: add missing filters from latest MSC ([\#2555](https://github.com/matrix-org/matrix-js-sdk/pull/2555)).
* Use stable prefixes for MSC3827 ([\#2537](https://github.com/matrix-org/matrix-js-sdk/pull/2537)).
## 🐛 Bug Fixes
* Don't load the sync accumulator if there's already a sync persist in flight ([\#2569](https://github.com/matrix-org/matrix-js-sdk/pull/2569)).
Changes in [19.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.2.0) (2022-08-02)
==================================================================================================
## 🦖 Deprecations
* Remove unstable support for `m.room_key.withheld` ([\#2512](https://github.com/matrix-org/matrix-js-sdk/pull/2512)). Fixes #2233.
## ✨ Features
* Sliding sync: add missing filters from latest MSC ([\#2555](https://github.com/matrix-org/matrix-js-sdk/pull/2555)).
* Use stable prefixes for MSC3827 ([\#2537](https://github.com/matrix-org/matrix-js-sdk/pull/2537)).
* Add support for MSC3575: Sliding Sync ([\#2242](https://github.com/matrix-org/matrix-js-sdk/pull/2242)).
## 🐛 Bug Fixes
* Correct the units in TURN servers expiry documentation ([\#2520](https://github.com/matrix-org/matrix-js-sdk/pull/2520)).
* Re-insert room IDs when decrypting bundled redaction events returned by `/sync` ([\#2531](https://github.com/matrix-org/matrix-js-sdk/pull/2531)). Contributed by @duxovni.
Changes in [19.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.1.0) (2022-07-26)
==================================================================================================
## 🦖 Deprecations
* Remove MSC3244 support ([\#2504](https://github.com/matrix-org/matrix-js-sdk/pull/2504)).
## ✨ Features
* `room` now exports `KNOWN_SAFE_ROOM_VERSION` ([\#2474](https://github.com/matrix-org/matrix-js-sdk/pull/2474)).
## 🐛 Bug Fixes
* Don't crash with undefined room in `processBeaconEvents()` ([\#2500](https://github.com/matrix-org/matrix-js-sdk/pull/2500)). Fixes #2494.
* Properly re-insert room ID in bundled thread relation messages from sync ([\#2505](https://github.com/matrix-org/matrix-js-sdk/pull/2505)). Fixes vector-im/element-web#22094. Contributed by @duxovni.
* Actually store the identity server in the client when given as an option ([\#2503](https://github.com/matrix-org/matrix-js-sdk/pull/2503)). Fixes vector-im/element-web#22757.
* Fix call.collectCallStats() ([\#2480](https://github.com/matrix-org/matrix-js-sdk/pull/2480)).
Changes in [19.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.0.0) (2022-07-05)
==================================================================================================
## 🚨 BREAKING CHANGES
* Remove unused sessionStore ([\#2455](https://github.com/matrix-org/matrix-js-sdk/pull/2455)).
## ✨ Features
* Implement MSC3827: Filtering of `/publicRooms` by room type ([\#2469](https://github.com/matrix-org/matrix-js-sdk/pull/2469)).
* expose latestLocationEvent on beacon model ([\#2467](https://github.com/matrix-org/matrix-js-sdk/pull/2467)). Contributed by @kerryarchibald.
* Live location share - add start time leniency ([\#2465](https://github.com/matrix-org/matrix-js-sdk/pull/2465)). Contributed by @kerryarchibald.
* Log real errors and not just their messages, traces are useful ([\#2464](https://github.com/matrix-org/matrix-js-sdk/pull/2464)).
* Various changes to `src/crypto` files for correctness ([\#2137](https://github.com/matrix-org/matrix-js-sdk/pull/2137)). Contributed by @ShadowJonathan.
* Update MSC3786 implementation: Check the `state_key` ([\#2429](https://github.com/matrix-org/matrix-js-sdk/pull/2429)).
* Timeline needs to refresh when we see a MSC2716 marker event ([\#2299](https://github.com/matrix-org/matrix-js-sdk/pull/2299)). Contributed by @MadLittleMods.
* Try to load keys from key backup when a message fails to decrypt ([\#2373](https://github.com/matrix-org/matrix-js-sdk/pull/2373)). Fixes vector-im/element-web#21026. Contributed by @duxovni.
## 🐛 Bug Fixes
* Send call version `1` as a string ([\#2471](https://github.com/matrix-org/matrix-js-sdk/pull/2471)). Fixes vector-im/element-web#22629.
* Fix issue with `getEventTimeline` returning undefined for thread roots in main timeline ([\#2454](https://github.com/matrix-org/matrix-js-sdk/pull/2454)). Fixes vector-im/element-web#22539.
* Add missing `type` property on `IAuthData` ([\#2463](https://github.com/matrix-org/matrix-js-sdk/pull/2463)).
* Clearly indicate that `lastReply` on a Thread can return falsy ([\#2462](https://github.com/matrix-org/matrix-js-sdk/pull/2462)).
* Fix issues with getEventTimeline and thread roots ([\#2444](https://github.com/matrix-org/matrix-js-sdk/pull/2444)). Fixes vector-im/element-web#21613.
* Live location sharing - monitor liveness of beacons yet to start ([\#2437](https://github.com/matrix-org/matrix-js-sdk/pull/2437)). Contributed by @kerryarchibald.
* Refactor Relations to not be per-EventTimelineSet ([\#2412](https://github.com/matrix-org/matrix-js-sdk/pull/2412)). Fixes #2399 and vector-im/element-web#22298.
* Add tests for sendEvent threadId handling ([\#2435](https://github.com/matrix-org/matrix-js-sdk/pull/2435)). Fixes vector-im/element-web#22433.
* Make sure `encryptAndSendKeysToDevices` assumes devices are unique per-user. ([\#2136](https://github.com/matrix-org/matrix-js-sdk/pull/2136)). Fixes #2135. Contributed by @ShadowJonathan.
* Don't bug the user while re-checking key backups after decryption failures ([\#2430](https://github.com/matrix-org/matrix-js-sdk/pull/2430)). Fixes vector-im/element-web#22416. Contributed by @duxovni.
Changes in [18.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.1.0) (2022-06-07)
==================================================================================================
## ✨ Features
* Convert `getLocalAliases` to a stable API call ([\#2402](https://github.com/matrix-org/matrix-js-sdk/pull/2402)).
## 🐛 Bug Fixes
* Fix request, crypto, and bs58 imports ([\#2414](https://github.com/matrix-org/matrix-js-sdk/pull/2414)). Fixes #2415.
* Update relations after every decryption attempt ([\#2387](https://github.com/matrix-org/matrix-js-sdk/pull/2387)). Fixes vector-im/element-web#22258. Contributed by @weeman1337.
* Fix degraded mode for the IDBStore and test it ([\#2400](https://github.com/matrix-org/matrix-js-sdk/pull/2400)). Fixes matrix-org/element-web-rageshakes#13170.
* Don't cancel SAS verifications if `ready` is received after `start` ([\#2250](https://github.com/matrix-org/matrix-js-sdk/pull/2250)).
* Prevent overlapping sync accumulator persists ([\#2392](https://github.com/matrix-org/matrix-js-sdk/pull/2392)). Fixes vector-im/element-web#21541.
* Fix behaviour of isRelation with relation m.replace for state events ([\#2389](https://github.com/matrix-org/matrix-js-sdk/pull/2389)). Fixes vector-im/element-web#22280.
* Fixes #2384 ([\#2385](https://github.com/matrix-org/matrix-js-sdk/pull/2385)). Fixes undefined/matrix-js-sdk#2384. Contributed by @schmop.
* Ensure rooms are recalculated on re-invites ([\#2374](https://github.com/matrix-org/matrix-js-sdk/pull/2374)). Fixes vector-im/element-web#22106.
Changes in [18.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.0.0) (2022-05-24)
==================================================================================================
## 🚨 BREAKING CHANGES (to experimental methods)
* Implement changes to MSC2285 (private read receipts) ([\#2221](https://github.com/matrix-org/matrix-js-sdk/pull/2221)).
## ✨ Features
* Add support for HTML renderings of room topics ([\#2272](https://github.com/matrix-org/matrix-js-sdk/pull/2272)).
* Add stopClient parameter to MatrixClient::logout ([\#2367](https://github.com/matrix-org/matrix-js-sdk/pull/2367)).
* registration: add function to re-request email token ([\#2357](https://github.com/matrix-org/matrix-js-sdk/pull/2357)).
* Remove hacky custom status feature ([\#2350](https://github.com/matrix-org/matrix-js-sdk/pull/2350)).
## 🐛 Bug Fixes
* Remove default push rule override for MSC1930 ([\#2376](https://github.com/matrix-org/matrix-js-sdk/pull/2376)). Fixes vector-im/element-web#15439.
* Tweak thread creation & event adding to fix bugs around relations ([\#2369](https://github.com/matrix-org/matrix-js-sdk/pull/2369)). Fixes vector-im/element-web#22162 and vector-im/element-web#22180.
* Prune both clear & wire content on redaction ([\#2346](https://github.com/matrix-org/matrix-js-sdk/pull/2346)). Fixes vector-im/element-web#21929.
* MSC3786: Add a default push rule to ignore `m.room.server_acl` events ([\#2333](https://github.com/matrix-org/matrix-js-sdk/pull/2333)). Fixes vector-im/element-web#20788.
Changes in [17.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.2.0) (2022-05-10)
==================================================================================================
## ✨ Features
* Live location sharing: handle encrypted messages in processBeaconEvents ([\#2327](https://github.com/matrix-org/matrix-js-sdk/pull/2327)).
## 🐛 Bug Fixes
* Fix race conditions around threads ([\#2331](https://github.com/matrix-org/matrix-js-sdk/pull/2331)). Fixes vector-im/element-web#21627.
* Ignore m.replace relations on state events, they're invalid ([\#2306](https://github.com/matrix-org/matrix-js-sdk/pull/2306)). Fixes vector-im/element-web#21851.
* fix example in readme ([\#2315](https://github.com/matrix-org/matrix-js-sdk/pull/2315)).
* Don't decrement the length count of a thread when root redacted ([\#2314](https://github.com/matrix-org/matrix-js-sdk/pull/2314)).
* Prevent attempt to create thread with id "undefined" ([\#2308](https://github.com/matrix-org/matrix-js-sdk/pull/2308)).
* Update threads handling for replies-to-thread-responses as per MSC update ([\#2305](https://github.com/matrix-org/matrix-js-sdk/pull/2305)). Fixes vector-im/element-web#19678.
Changes in [17.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.1.0) (2022-04-26)
==================================================================================================
## ✨ Features
* Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 ([\#2297](https://github.com/matrix-org/matrix-js-sdk/pull/2297)).
* Live location sharing - expose room liveBeaconIds ([\#2296](https://github.com/matrix-org/matrix-js-sdk/pull/2296)).
* Support for MSC2457 logout_devices param for setPassword() ([\#2285](https://github.com/matrix-org/matrix-js-sdk/pull/2285)).
* Stabilise token authenticated registration support ([\#2181](https://github.com/matrix-org/matrix-js-sdk/pull/2181)). Contributed by @govynnus.
* Live location sharing - Aggregate beacon locations on beacons ([\#2268](https://github.com/matrix-org/matrix-js-sdk/pull/2268)).
## 🐛 Bug Fixes
* Prevent duplicated re-emitter setups in event-mapper ([\#2293](https://github.com/matrix-org/matrix-js-sdk/pull/2293)).
* Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661.
Changes in [17.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0) (2022-04-11)
==================================================================================================
@@ -670,7 +806,7 @@ Changes in [11.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/ta
BREAKING CHANGES
---
* `MatrixCall` and related APIs have been redesigned to support multiple streams
* `MatrixCall` and related APIs have been redesigned to support multiple streams
(see [\#1660](https://github.com/matrix-org/matrix-js-sdk/pull/1660) for more details)
All changes
@@ -1343,7 +1479,7 @@ BREAKING CHANGES
---
* `RoomState` events changed to use a Map instead of an object, which changes the collection APIs available to access them.
All Changes
---
+45 -6
View File
@@ -23,7 +23,18 @@ Things that should go into your PR description:
* A changelog entry in the `Notes` section (see below)
* References to any bugs fixed by the change (in GitHub's `Fixes` notation)
* Describe the why and what is changing in the PR description so it's easy for
onlookers and reviewers to onboard and context switch.
onlookers and reviewers to onboard and context switch. This information is
also helpful when we come back to look at this in 6 months and ask "why did
we do it like that?" we have a chance of finding out.
* Why didn't it work before? Why does it work now? What use cases does it
unlock?
* If you find yourself adding information on how the code works or why you
chose to do it the way you did, make sure this information is instead
written as comments in the code itself.
* Sometimes a PR can change considerably as it is developed. In this case,
the description should be updated to reflect the most recent state of
the PR. (It can be helpful to retain the old content under a suitable
heading, for additional context.)
* Include both **before** and **after** screenshots to easily compare and discuss
what's changing.
* Include a step-by-step testing strategy so that a reviewer can check out the
@@ -31,11 +42,6 @@ Things that should go into your PR description:
* Add comments to the diff for the reviewer that might help them to understand
why the change is necessary or how they might better understand and review it.
Things that should *not* go into your PR description:
* Any information on how the code works or why you chose to do it the way
you did. If this isn't obvious from your code, you haven't written enough
comments.
We rely on information in pull request to populate the information that goes
into the changelogs our users see, both for the JS SDK itself and also for some
projects based on it. This is picked up from both labels on the pull request and
@@ -129,6 +135,16 @@ When writing unit tests, please aim for a high level of test coverage
for new code - 80% or greater. If you cannot achieve that, please document
why it's not possible in your PR.
Some sections of code are not sensible to add coverage for, such as those
which explicitly inhibit noisy logging for tests. Which can be hidden using
an istanbul magic comment as [documented here][1]. See example:
```javascript
/* istanbul ignore if */
if (process.env.NODE_ENV !== "test") {
logger.error("Log line that is noisy enough in tests to want to skip");
}
```
Tests validate that your change works as intended and also document
concisely what is being changed. Ideally, your new tests fail
prior to your change, and succeed once it has been applied. You may
@@ -243,3 +259,26 @@ on Git 2.17+ you can mass signoff using rebase:
```
git rebase --signoff origin/develop
```
Review expectations
===================
See https://github.com/vector-im/element-meta/wiki/Review-process
Merge Strategy
==============
The preferred method for merging pull requests is squash merging to keep the
commit history trim, but it is up to the discretion of the team member merging
the change. We do not support rebase merges due to `allchange` being unable to
handle them. When merging make sure to leave the default commit title, or
at least leave the PR number at the end in brackets like by default.
When stacking pull requests, you may wish to do the following:
1. Branch from develop to your branch (branch1), push commits onto it and open a pull request
2. Branch from your base branch (branch1) to your work branch (branch2), push commits and open a pull request configuring the base to be branch1, saying in the description that it is based on your other PR.
3. Merge the first PR using a merge commit otherwise your stacked PR will need a rebase. Github will automatically adjust the base branch of your other PR to be develop.
[1]: https://github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md
+12 -4
View File
@@ -1,3 +1,11 @@
[![npm](https://img.shields.io/npm/v/matrix-js-sdk)](https://www.npmjs.com/package/matrix-js-sdk)
![Tests](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/tests.yml/badge.svg)
![Static Analysis](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/static_analysis.yml/badge.svg)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=coverage)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=bugs)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
Matrix Javascript SDK
=====================
@@ -16,7 +24,7 @@ attached to ``window`` through which you can access the SDK. See below for how t
include libolm to enable end-to-end-encryption.
The browser bundle supports recent versions of browsers. Typically this is ES2015
or `> 0.5%, last 2 versions, Firefox ESR, not dead` if using
or `> 0.5%, last 2 versions, Firefox ESR, not dead` if using
[browserlists](https://github.com/browserslist/browserslist).
Please check [the working browser example](examples/browser) for more information.
@@ -26,11 +34,11 @@ In Node.js
Ensure you have the latest LTS version of Node.js installed.
This SDK targets Node 10 for compatibility, which translates to ES6. If you're using
This SDK targets Node 12 for compatibility, which translates to ES6. If you're using
a bundler like webpack you'll likely have to transpile dependencies, including this
SDK, to match your target browsers.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
``yarn add matrix-js-sdk``
@@ -307,7 +315,7 @@ The SDK supports end-to-end encryption via the Olm and Megolm protocols, using
[libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the
application to make libolm available, via the ``Olm`` global.
It is also necessary to call ``matrixClient.initCrypto()`` after creating a new
It is also necessary to call ``await matrixClient.initCrypto()`` after creating a new
``MatrixClient`` (but **before** calling ``matrixClient.startClient()``) to
initialise the crypto layer.
+2 -2
View File
@@ -341,7 +341,7 @@ function printLine(event) {
var maxNameWidth = 15;
if (name.length > maxNameWidth) {
name = name.substr(0, maxNameWidth-1) + "\u2026";
name = name.slice(0, maxNameWidth-1) + "\u2026";
}
if (event.getType() === "m.room.message") {
@@ -398,7 +398,7 @@ function print(str, formatter) {
function fixWidth(str, len) {
if (str.length > len) {
return str.substr(0, len-2) + "\u2026";
return str.substring(0, len-2) + "\u2026";
}
else if (str.length < len) {
return str + new Array(len - str.length).join(" ");
+25 -16
View File
@@ -1,7 +1,10 @@
{
"name": "matrix-js-sdk",
"version": "17.0.0",
"version": "19.3.0-rc.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
},
"scripts": {
"prepublishOnly": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
@@ -53,11 +56,11 @@
"@babel/runtime": "^7.12.5",
"another-json": "^0.2.0",
"browser-request": "^0.3.3",
"bs58": "^4.0.1",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"loglevel": "^1.7.1",
"matrix-events-sdk": "^0.0.1-beta.7",
"p-retry": "^4.5.0",
"p-retry": "4",
"qs": "^6.9.6",
"request": "^2.88.2",
"unhomoglyph": "^1.0.6"
@@ -75,30 +78,31 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/jest": "^26.0.20",
"@types/node": "12",
"@types/jest": "^28.0.0",
"@types/node": "16",
"@types/request": "^2.48.5",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"allchange": "^1.0.6",
"babel-jest": "^26.6.3",
"babel-jest": "^28.0.0",
"babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"docdash": "^1.2.0",
"eslint": "8.9.0",
"eslint": "8.20.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-matrix-org": "^0.4.0",
"exorcist": "^1.0.1",
"fake-indexeddb": "^3.1.2",
"jest": "^26.6.3",
"eslint-plugin-matrix-org": "^0.5.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"jest": "^28.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
"matrix-mock-request": "^1.2.3",
"matrix-mock-request": "^2.1.2",
"rimraf": "^3.0.2",
"terser": "^5.5.1",
"tsify": "^5.0.2",
@@ -113,9 +117,14 @@
"<rootDir>/src/**/*.{js,ts}"
],
"coverageReporters": [
"text",
"json"
]
"text-summary",
"lcov"
],
"testResultsProcessor": "jest-sonar-reporter"
},
"jestSonar": {
"reportPath": "coverage",
"sonar56x": true
},
"typings": "./lib/index.d.ts"
}
+7 -26
View File
@@ -29,7 +29,7 @@ fi
npm --version > /dev/null || (echo "npm is required: please install it"; kill $$)
yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$)
USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z"
USAGE="$0 [-x] [-c changelog_file] vX.Y.Z"
help() {
cat <<EOF
@@ -37,7 +37,6 @@ $USAGE
-c changelog_file: specify name of file containing changelog
-x: skip updating the changelog
-z: skip generating the jsdoc
-n: skip publish to NPM
EOF
}
@@ -60,7 +59,6 @@ if ! git diff-files --quiet; then
fi
skip_changelog=
skip_jsdoc=
skip_npm=
changelog_file="CHANGELOG.md"
expected_npm_user="matrixdotorg"
@@ -76,9 +74,6 @@ while getopts hc:u:xzn f; do
x)
skip_changelog=1
;;
z)
skip_jsdoc=1
;;
n)
skip_npm=1
;;
@@ -126,14 +121,16 @@ echo $release | grep -q '-' && prerelease=1
if [ $prerelease -eq 1 ]; then
echo Making a PRE-RELEASE
else
read -p "Making a FINAL RELEASE, press enter to continue " REPLY
fi
# we might already be on the release branch, in which case, yay
# If we're on any branch starting with 'release', we don't create
# a separate release branch (this allows us to use the same
# We might already be on the release branch, in which case, yay
# If we're on any branch starting with 'release', or the staging branch
# we don't create a separate release branch (this allows us to use the same
# release branch for releases and release candidates).
curbranch=$(git symbolic-ref --short HEAD)
if [[ "$curbranch" != release* ]]; then
if [[ "$curbranch" != release* && "$curbranch" != "staging" ]]; then
echo "Creating release branch"
git checkout -b "$rel_branch"
else
@@ -326,22 +323,6 @@ if [ -z "$skip_npm" ]; then
fi
fi
if [ -z "$skip_jsdoc" ]; then
echo "generating jsdocs"
yarn gendoc
echo "copying jsdocs to gh-pages branch"
git checkout gh-pages
git pull
cp -a ".jsdoc/matrix-js-sdk/$release" .
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' \
$release index.html
git add "$release"
git commit --no-verify -m "Add jsdoc for $release" index.html "$release"
git push origin gh-pages
fi
# if it is a pre-release, leave it on the release branch for now.
if [ $prerelease -eq 1 ]; then
git checkout "$rel_branch"
+16
View File
@@ -0,0 +1,16 @@
sonar.projectKey=matrix-js-sdk
sonar.organization=matrix-org
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8
sonar.sources=src
sonar.tests=spec
sonar.exclusions=docs,examples,git-hooks
sonar.typescript.tsconfigPath=./tsconfig.json
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.coverage.exclusions=spec/**/*
sonar.testExecutionReportPaths=coverage/test-report.xml
sonar.lang.patterns.ts=**/*.ts,**/*.tsx
-238
View File
@@ -1,238 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
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.
*/
// load olm before the sdk if possible
import './olm-loader';
import MockHttpBackend from 'matrix-mock-request';
import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store';
import { logger } from '../src/logger';
import { WebStorageSessionStore } from "../src/store/session/webstorage";
import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix";
import { MockStorageApi } from "./MockStorageApi";
/**
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
*
* @constructor
* @param {string} userId
* @param {string} deviceId
* @param {string} accessToken
*
* @param {WebStorage=} sessionStoreBackend a web storage object to use for the
* session store. If undefined, we will create a MockStorageApi.
* @param {object} options additional options to pass to the client
*/
export function TestClient(
userId, deviceId, accessToken, sessionStoreBackend, options,
) {
this.userId = userId;
this.deviceId = deviceId;
if (sessionStoreBackend === undefined) {
sessionStoreBackend = new MockStorageApi();
}
const sessionStore = new WebStorageSessionStore(sessionStoreBackend);
this.httpBackend = new MockHttpBackend();
options = Object.assign({
baseUrl: "http://" + userId + ".test.server",
userId: userId,
accessToken: accessToken,
deviceId: deviceId,
sessionStore: sessionStore,
request: this.httpBackend.requestFn,
}, options);
if (!options.cryptoStore) {
// expose this so the tests can get to it
this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend);
options.cryptoStore = this.cryptoStore;
}
this.client = createClient(options);
this.deviceKeys = null;
this.oneTimeKeys = {};
this.callEventHandler = {
calls: new Map(),
};
}
TestClient.prototype.toString = function() {
return 'TestClient[' + this.userId + ']';
};
/**
* start the client, and wait for it to initialise.
*
* @return {Promise}
*/
TestClient.prototype.start = function() {
logger.log(this + ': starting');
this.httpBackend.when("GET", "/versions").respond(200, {});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 });
this.client.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: 'detached',
});
return Promise.all([
this.httpBackend.flushAllExpected(),
syncPromise(this.client),
]).then(() => {
logger.log(this + ': started');
});
};
/**
* stop the client
* @return {Promise} Resolves once the mock http backend has finished all pending flushes
*/
TestClient.prototype.stop = function() {
this.client.stopClient();
return this.httpBackend.stop();
};
/**
* Set up expectations that the client will upload device keys.
*/
TestClient.prototype.expectDeviceKeyUpload = function() {
const self = this;
this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) {
expect(content.one_time_keys).toBe(undefined);
expect(content.device_keys).toBeTruthy();
logger.log(self + ': received device keys');
// we expect this to happen before any one-time keys are uploaded.
expect(Object.keys(self.oneTimeKeys).length).toEqual(0);
self.deviceKeys = content.device_keys;
return { one_time_key_counts: { signed_curve25519: 0 } };
});
};
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
* that to happen.
*
* @returns {Promise} for the one-time keys
*/
TestClient.prototype.awaitOneTimeKeyUpload = function() {
if (Object.keys(this.oneTimeKeys).length != 0) {
// already got one-time keys
return Promise.resolve(this.oneTimeKeys);
}
this.httpBackend.when("POST", "/keys/upload")
.respond(200, (path, content) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBe(undefined);
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
this.httpBackend.when("POST", "/keys/upload")
.respond(200, (path, content) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
logger.log('%s: received %i one-time keys', this,
Object.keys(content.one_time_keys).length);
this.oneTimeKeys = content.one_time_keys;
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
// this can take ages
return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => {
expect(flushed).toEqual(2);
return this.oneTimeKeys;
});
};
/**
* Set up expectations that the client will query device keys.
*
* We check that the query contains each of the users in `response`.
*
* @param {Object} response response to the query.
*/
TestClient.prototype.expectKeyQuery = function(response) {
this.httpBackend.when('POST', '/keys/query').respond(
200, (path, content) => {
Object.keys(response.device_keys).forEach((userId) => {
expect(content.device_keys[userId]).toEqual(
[],
"Expected key query for " + userId + ", got " +
Object.keys(content.device_keys),
);
});
return response;
});
};
/**
* get the uploaded curve25519 device key
*
* @return {string} base64 device key
*/
TestClient.prototype.getDeviceKey = function() {
const keyId = 'curve25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
};
/**
* get the uploaded ed25519 device key
*
* @return {string} base64 device key
*/
TestClient.prototype.getSigningKey = function() {
const keyId = 'ed25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
};
/**
* flush a single /sync request, and wait for the syncing event
*
* @returns {Promise} promise which completes once the sync has been flushed
*/
TestClient.prototype.flushSync = function() {
logger.log(`${this}: flushSync`);
return Promise.all([
this.httpBackend.flush('/sync', 1),
syncPromise(this.client),
]).then(() => {
logger.log(`${this}: flushSync completed`);
});
};
TestClient.prototype.isFallbackICEServerAllowed = function() {
return true;
};
+243
View File
@@ -0,0 +1,243 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
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.
*/
// load olm before the sdk if possible
import './olm-loader';
import MockHttpBackend from 'matrix-mock-request';
import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store';
import { logger } from '../src/logger';
import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils";
import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration";
import { IKeyBackupSession } from "../src/crypto/keybackup";
import { IHttpOpts } from "../src/http-api";
import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client';
/**
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
*/
export class TestClient {
public readonly httpBackend: MockHttpBackend;
public readonly client: MatrixClient;
public deviceKeys: IDeviceKeys;
public oneTimeKeys: Record<string, IOneTimeKey>;
constructor(
public readonly userId?: string,
public readonly deviceId?: string,
accessToken?: string,
sessionStoreBackend?: Storage,
options?: Partial<ICreateClientOpts>,
) {
if (sessionStoreBackend === undefined) {
sessionStoreBackend = new MockStorageApi();
}
this.httpBackend = new MockHttpBackend();
const fullOptions: ICreateClientOpts = {
baseUrl: "http://" + userId + ".test.server",
userId: userId,
accessToken: accessToken,
deviceId: deviceId,
request: this.httpBackend.requestFn as IHttpOpts["request"],
...options,
};
if (!fullOptions.cryptoStore) {
// expose this so the tests can get to it
fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend);
}
this.client = createClient(fullOptions);
this.deviceKeys = null;
this.oneTimeKeys = {};
}
public toString(): string {
return 'TestClient[' + this.userId + ']';
}
/**
* start the client, and wait for it to initialise.
*/
public start(): Promise<void> {
logger.log(this + ': starting');
this.httpBackend.when("GET", "/versions").respond(200, {});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 });
this.client.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached,
});
return Promise.all([
this.httpBackend.flushAllExpected(),
syncPromise(this.client),
]).then(() => {
logger.log(this + ': started');
});
}
/**
* stop the client
* @return {Promise} Resolves once the mock http backend has finished all pending flushes
*/
public async stop(): Promise<void> {
this.client.stopClient();
await this.httpBackend.stop();
}
/**
* Set up expectations that the client will upload device keys.
*/
public expectDeviceKeyUpload() {
this.httpBackend.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content) => {
expect(content.one_time_keys).toBe(undefined);
expect(content.device_keys).toBeTruthy();
logger.log(this + ': received device keys');
// we expect this to happen before any one-time keys are uploaded.
expect(Object.keys(this.oneTimeKeys).length).toEqual(0);
this.deviceKeys = content.device_keys;
return { one_time_key_counts: { signed_curve25519: 0 } };
});
}
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
* that to happen.
*
* @returns {Promise} for the one-time keys
*/
public awaitOneTimeKeyUpload(): Promise<Record<string, IOneTimeKey>> {
if (Object.keys(this.oneTimeKeys).length != 0) {
// already got one-time keys
return Promise.resolve(this.oneTimeKeys);
}
this.httpBackend.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content: IUploadKeysRequest) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBe(undefined);
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
this.httpBackend.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content: IUploadKeysRequest) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
logger.log('%s: received %i one-time keys', this,
Object.keys(content.one_time_keys).length);
this.oneTimeKeys = content.one_time_keys;
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
// this can take ages
return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => {
expect(flushed).toEqual(2);
return this.oneTimeKeys;
});
}
/**
* Set up expectations that the client will query device keys.
*
* We check that the query contains each of the users in `response`.
*
* @param {Object} response response to the query.
*/
public expectKeyQuery(response: IDownloadKeyResult) {
this.httpBackend.when('POST', '/keys/query').respond<IDownloadKeyResult>(
200, (_path, content) => {
Object.keys(response.device_keys).forEach((userId) => {
expect(content.device_keys[userId]).toEqual([]);
});
return response;
});
}
/**
* Set up expectations that the client will query key backups for a particular session
*/
public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) {
this.httpBackend.when('GET', encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: roomId,
$sessionId: sessionId,
})).respond(status, response);
}
/**
* get the uploaded curve25519 device key
*
* @return {string} base64 device key
*/
public getDeviceKey(): string {
const keyId = 'curve25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
}
/**
* get the uploaded ed25519 device key
*
* @return {string} base64 device key
*/
public getSigningKey(): string {
const keyId = 'ed25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
}
/**
* flush a single /sync request, and wait for the syncing event
*/
public flushSync(): Promise<void> {
logger.log(`${this}: flushSync`);
return Promise.all([
this.httpBackend.flush('/sync', 1),
syncPromise(this.client),
]).then(() => {
logger.log(`${this}: flushSync completed`);
});
}
public isFallbackICEServerAllowed(): boolean {
return true;
}
public getUserId(): string {
return this.userId;
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ describe("Browserify Test", function() {
httpBackend.stop();
});
it("Sync", async function() {
it("Sync", function() {
const event = utils.mkMembership({
room: ROOM_ID,
mship: "join",
@@ -71,7 +71,7 @@ describe("Browserify Test", function() {
};
httpBackend.when("GET", "/sync").respond(200, syncData);
return await Promise.race([
return Promise.race([
httpBackend.flushAllExpected(),
new Promise((_, reject) => {
client.once("sync.unexpectedError", reject);
@@ -136,138 +136,137 @@ describe("DeviceList management:", function() {
});
});
it("We should not get confused by out-of-order device query responses",
() => {
// https://github.com/vector-im/element-web/issues/3126
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } });
return aliceTestClient.start().then(() => {
aliceTestClient.httpBackend.when('GET', '/sync').respond(
200, getSyncResponse(['@bob:xyz', '@chris:abc']));
return aliceTestClient.flushSync();
}).then(() => {
// to make sure the initial device queries are flushed out, we
// attempt to send a message.
it.skip("We should not get confused by out-of-order device query responses", () => {
// https://github.com/vector-im/element-web/issues/3126
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } });
return aliceTestClient.start().then(() => {
aliceTestClient.httpBackend.when('GET', '/sync').respond(
200, getSyncResponse(['@bob:xyz', '@chris:abc']));
return aliceTestClient.flushSync();
}).then(() => {
// to make sure the initial device queries are flushed out, we
// attempt to send a message.
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, {
device_keys: {
'@bob:xyz': {},
'@chris:abc': {},
},
},
);
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, {
device_keys: {
'@bob:xyz': {},
'@chris:abc': {},
},
},
);
aliceTestClient.httpBackend.when('PUT', '/send/').respond(
200, { event_id: '$event1' });
aliceTestClient.httpBackend.when('PUT', '/send/').respond(
200, { event_id: '$event1' });
return Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
aliceTestClient.httpBackend.flush('/keys/query', 1).then(
() => aliceTestClient.httpBackend.flush('/send/', 1),
),
aliceTestClient.client.crypto.deviceList.saveIfDirty(),
]);
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
expect(data.syncToken).toEqual(1);
});
return Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
aliceTestClient.httpBackend.flush('/keys/query', 1).then(
() => aliceTestClient.httpBackend.flush('/send/', 1),
),
aliceTestClient.client.crypto.deviceList.saveIfDirty(),
]);
}).then(() => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
expect(data.syncToken).toEqual(1);
});
// invalidate bob's and chris's device lists in separate syncs
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
next_batch: '2',
device_lists: {
changed: ['@bob:xyz'],
},
});
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
next_batch: '3',
device_lists: {
changed: ['@chris:abc'],
},
});
// flush both syncs
return aliceTestClient.flushSync().then(() => {
return aliceTestClient.flushSync();
});
}).then(() => {
// check that we don't yet have a request for chris's devices.
aliceTestClient.httpBackend.when('POST', '/keys/query', {
device_keys: {
'@chris:abc': {},
},
token: '3',
}).respond(200, {
device_keys: { '@chris:abc': {} },
});
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(0);
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
if (bobStat != 1 && bobStat != 2) {
throw new Error('Unexpected status for bob: wanted 1 or 2, got ' +
bobStat);
}
const chrisStat = data.trackingStatus['@chris:abc'];
if (chrisStat != 1 && chrisStat != 2) {
throw new Error(
'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat,
);
}
});
// invalidate bob's and chris's device lists in separate syncs
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
next_batch: '2',
device_lists: {
changed: ['@bob:xyz'],
},
});
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
next_batch: '3',
device_lists: {
changed: ['@chris:abc'],
},
});
// flush both syncs
return aliceTestClient.flushSync().then(() => {
return aliceTestClient.flushSync();
});
}).then(() => {
// check that we don't yet have a request for chris's devices.
aliceTestClient.httpBackend.when('POST', '/keys/query', {
device_keys: {
'@chris:abc': {},
},
token: '3',
}).respond(200, {
device_keys: { '@chris:abc': {} },
});
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(0);
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
if (bobStat != 1 && bobStat != 2) {
throw new Error('Unexpected status for bob: wanted 1 or 2, got ' +
bobStat);
}
const chrisStat = data.trackingStatus['@chris:abc'];
if (chrisStat != 1 && chrisStat != 2) {
throw new Error(
'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat,
);
}
});
// now add an expectation for a query for bob's devices, and let
// it complete.
aliceTestClient.httpBackend.when('POST', '/keys/query', {
device_keys: {
'@bob:xyz': {},
},
token: '2',
}).respond(200, {
device_keys: { '@bob:xyz': {} },
});
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(1);
// now add an expectation for a query for bob's devices, and let
// it complete.
aliceTestClient.httpBackend.when('POST', '/keys/query', {
device_keys: {
'@bob:xyz': {},
},
token: '2',
}).respond(200, {
device_keys: { '@bob:xyz': {} },
});
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(1);
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@bob:xyz']);
}).then(() => {
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(3);
const chrisStat = data.trackingStatus['@chris:abc'];
if (chrisStat != 1 && chrisStat != 2) {
throw new Error(
'Unexpected status for chris: wanted 1 or 2, got ' + bobStat,
);
}
});
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@bob:xyz']);
}).then(() => {
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(3);
const chrisStat = data.trackingStatus['@chris:abc'];
if (chrisStat != 1 && chrisStat != 2) {
throw new Error(
'Unexpected status for chris: wanted 1 or 2, got ' + bobStat,
);
}
});
// now let the query for chris's devices complete.
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(1);
// now let the query for chris's devices complete.
return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => {
expect(flushed).toEqual(1);
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@chris:abc']);
}).then(() => {
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
const chrisStat = data.trackingStatus['@bob:xyz'];
// wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@chris:abc']);
}).then(() => {
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
const chrisStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(3);
expect(chrisStat).toEqual(3);
expect(data.syncToken).toEqual(3);
});
});
}).timeout(3000);
expect(bobStat).toEqual(3);
expect(chrisStat).toEqual(3);
expect(data.syncToken).toEqual(3);
});
});
});
// https://github.com/vector-im/element-web/issues/4983
describe("Alice should know she has stale device lists", () => {
@@ -288,7 +287,7 @@ describe("DeviceList management:", function() {
await aliceTestClient.httpBackend.flush('/keys/query', 1);
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toBeGreaterThan(
@@ -324,7 +323,7 @@ describe("DeviceList management:", function() {
await aliceTestClient.flushSync();
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(
@@ -360,7 +359,7 @@ describe("DeviceList management:", function() {
await aliceTestClient.flushSync();
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(
@@ -381,7 +380,7 @@ describe("DeviceList management:", function() {
await anotherTestClient.flushSync();
await anotherTestClient.client.crypto.deviceList.saveIfDirty();
anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(
-758
View File
@@ -1,758 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* This file consists of a set of integration tests which try to simulate
* communication via an Olm-encrypted room between two users, Alice and Bob.
*
* Note that megolm (group) conversation is not tested here.
*
* See also `megolm.spec.js`.
*/
// load olm before the sdk if possible
import '../olm-loader';
import { logger } from '../../src/logger';
import * as testUtils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { CRYPTO_ENABLED } from "../../src/client";
let aliTestClient;
const roomId = "!room:localhost";
const aliUserId = "@ali:localhost";
const aliDeviceId = "zxcvb";
const aliAccessToken = "aseukfgwef";
let bobTestClient;
const bobUserId = "@bob:localhost";
const bobDeviceId = "bvcxz";
const bobAccessToken = "fewgfkuesa";
let aliMessages;
let bobMessages;
function bobUploadsDeviceKeys() {
bobTestClient.expectDeviceKeyUpload();
return Promise.all([
bobTestClient.client.uploadKeys(),
bobTestClient.httpBackend.flush(),
]).then(() => {
expect(Object.keys(bobTestClient.deviceKeys).length).not.toEqual(0);
});
}
/**
* Set an expectation that ali will query bobs keys; then flush the http request.
*
* @return {promise} resolves once the http request has completed.
*/
function expectAliQueryKeys() {
// can't query keys before bob has uploaded them
expect(bobTestClient.deviceKeys).toBeTruthy();
const bobKeys = {};
bobKeys[bobDeviceId] = bobTestClient.deviceKeys;
aliTestClient.httpBackend.when("POST", "/keys/query")
.respond(200, function(path, content) {
expect(content.device_keys[bobUserId]).toEqual(
[],
"Expected Alice to key query for " + bobUserId + ", got " +
Object.keys(content.device_keys),
);
const result = {};
result[bobUserId] = bobKeys;
return { device_keys: result };
});
return aliTestClient.httpBackend.flush("/keys/query", 1);
}
/**
* Set an expectation that bob will query alis keys; then flush the http request.
*
* @return {promise} which resolves once the http request has completed.
*/
function expectBobQueryKeys() {
// can't query keys before ali has uploaded them
expect(aliTestClient.deviceKeys).toBeTruthy();
const aliKeys = {};
aliKeys[aliDeviceId] = aliTestClient.deviceKeys;
logger.log("query result will be", aliKeys);
bobTestClient.httpBackend.when(
"POST", "/keys/query",
).respond(200, function(path, content) {
expect(content.device_keys[aliUserId]).toEqual(
[],
"Expected Bob to key query for " + aliUserId + ", got " +
Object.keys(content.device_keys),
);
const result = {};
result[aliUserId] = aliKeys;
return { device_keys: result };
});
return bobTestClient.httpBackend.flush("/keys/query", 1);
}
/**
* Set an expectation that ali will claim one of bob's keys; then flush the http request.
*
* @return {promise} resolves once the http request has completed.
*/
function expectAliClaimKeys() {
return bobTestClient.awaitOneTimeKeyUpload().then((keys) => {
aliTestClient.httpBackend.when(
"POST", "/keys/claim",
).respond(200, function(path, content) {
const claimType = content.one_time_keys[bobUserId][bobDeviceId];
expect(claimType).toEqual("signed_curve25519");
let keyId = null;
for (keyId in keys) {
if (bobTestClient.oneTimeKeys.hasOwnProperty(keyId)) {
if (keyId.indexOf(claimType + ":") === 0) {
break;
}
}
}
const result = {};
result[bobUserId] = {};
result[bobUserId][bobDeviceId] = {};
result[bobUserId][bobDeviceId][keyId] = keys[keyId];
return { one_time_keys: result };
});
}).then(() => {
// it can take a while to process the key query, so give it some extra
// time, and make sure the claim actually happens rather than ploughing on
// confusingly.
return aliTestClient.httpBackend.flush("/keys/claim", 1, 500).then((r) => {
expect(r).toEqual(1, "Ali did not claim Bob's keys");
});
});
}
function aliDownloadsKeys() {
// can't query keys before bob has uploaded them
expect(bobTestClient.getSigningKey()).toBeTruthy();
const p1 = aliTestClient.client.downloadKeys([bobUserId]).then(function() {
return aliTestClient.client.getStoredDevicesForUser(bobUserId);
}).then((devices) => {
expect(devices.length).toEqual(1);
expect(devices[0].deviceId).toEqual("bvcxz");
});
const p2 = expectAliQueryKeys();
// check that the localStorage is updated as we expect (not sure this is
// an integration test, but meh)
return Promise.all([p1, p2]).then(() => {
return aliTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => {
aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data.devices[bobUserId];
expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys);
expect(devices[bobDeviceId].verified).
toBe(0); // DeviceVerification.UNVERIFIED
});
});
}
function aliEnablesEncryption() {
return aliTestClient.client.setRoomEncryption(roomId, {
algorithm: "m.olm.v1.curve25519-aes-sha2",
}).then(function() {
expect(aliTestClient.client.isRoomEncrypted(roomId)).toBeTruthy();
});
}
function bobEnablesEncryption() {
return bobTestClient.client.setRoomEncryption(roomId, {
algorithm: "m.olm.v1.curve25519-aes-sha2",
}).then(function() {
expect(bobTestClient.client.isRoomEncrypted(roomId)).toBeTruthy();
});
}
/**
* Ali sends a message, first claiming e2e keys. Set the expectations and
* check the results.
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
function aliSendsFirstMessage() {
return Promise.all([
sendMessage(aliTestClient.client),
expectAliQueryKeys()
.then(expectAliClaimKeys)
.then(expectAliSendMessageRequest),
]).then(function([_, ciphertext]) {
return ciphertext;
});
}
/**
* Ali sends a message without first claiming e2e keys. Set the expectations
* and check the results.
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
function aliSendsMessage() {
return Promise.all([
sendMessage(aliTestClient.client),
expectAliSendMessageRequest(),
]).then(function([_, ciphertext]) {
return ciphertext;
});
}
/**
* Bob sends a message, first querying (but not claiming) e2e keys. Set the
* expectations and check the results.
*
* @return {promise} which resolves to the ciphertext for Ali's device.
*/
function bobSendsReplyMessage() {
return Promise.all([
sendMessage(bobTestClient.client),
expectBobQueryKeys()
.then(expectBobSendMessageRequest),
]).then(function([_, ciphertext]) {
return ciphertext;
});
}
/**
* Set an expectation that Ali will send a message, and flush the request
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
function expectAliSendMessageRequest() {
return expectSendMessageRequest(aliTestClient.httpBackend).then(function(content) {
aliMessages.push(content);
expect(Object.keys(content.ciphertext)).toEqual([bobTestClient.getDeviceKey()]);
const ciphertext = content.ciphertext[bobTestClient.getDeviceKey()];
expect(ciphertext).toBeTruthy();
return ciphertext;
});
}
/**
* Set an expectation that Bob will send a message, and flush the request
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
function expectBobSendMessageRequest() {
return expectSendMessageRequest(bobTestClient.httpBackend).then(function(content) {
bobMessages.push(content);
const aliKeyId = "curve25519:" + aliDeviceId;
const aliDeviceCurve25519Key = aliTestClient.deviceKeys.keys[aliKeyId];
expect(Object.keys(content.ciphertext)).toEqual([aliDeviceCurve25519Key]);
const ciphertext = content.ciphertext[aliDeviceCurve25519Key];
expect(ciphertext).toBeTruthy();
return ciphertext;
});
}
function sendMessage(client) {
return client.sendMessage(
roomId, { msgtype: "m.text", body: "Hello, World" },
);
}
function expectSendMessageRequest(httpBackend) {
const path = "/send/m.room.encrypted/";
const prom = new Promise((resolve) => {
httpBackend.when("PUT", path).respond(200, function(path, content) {
resolve(content);
return {
event_id: "asdfgh",
};
});
});
// it can take a while to process the key query
return httpBackend.flush(path, 1).then(() => prom);
}
function aliRecvMessage() {
const message = bobMessages.shift();
return recvMessage(
aliTestClient.httpBackend, aliTestClient.client, bobUserId, message,
);
}
function bobRecvMessage() {
const message = aliMessages.shift();
return recvMessage(
bobTestClient.httpBackend, bobTestClient.client, aliUserId, message,
);
}
function recvMessage(httpBackend, client, sender, message) {
const syncData = {
next_batch: "x",
rooms: {
join: {
},
},
};
syncData.rooms.join[roomId] = {
timeline: {
events: [
testUtils.mkEvent({
type: "m.room.encrypted",
room: roomId,
content: message,
sender: sender,
}),
],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
const eventPromise = new Promise((resolve, reject) => {
const onEvent = function(event) {
// ignore the m.room.member events
if (event.getType() == "m.room.member") {
return;
}
logger.log(client.credentials.userId + " received event",
event);
client.removeListener("event", onEvent);
resolve(event);
};
client.on("event", onEvent);
});
httpBackend.flush();
return eventPromise.then((event) => {
expect(event.isEncrypted()).toBeTruthy();
// it may still be being decrypted
return testUtils.awaitDecryption(event);
}).then((event) => {
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent()).toMatchObject({
msgtype: "m.text",
body: "Hello, World",
});
expect(event.isEncrypted()).toBeTruthy();
});
}
/**
* Send an initial sync response to the client (which just includes the member
* list for our test room).
*
* @param {TestClient} testClient
* @returns {Promise} which resolves when the sync has been flushed.
*/
function firstSync(testClient) {
// send a sync response including our test room.
const syncData = {
next_batch: "x",
rooms: {
join: { },
},
};
syncData.rooms.join[roomId] = {
state: {
events: [
testUtils.mkMembership({
mship: "join",
user: aliUserId,
}),
testUtils.mkMembership({
mship: "join",
user: bobUserId,
}),
],
},
timeline: {
events: [],
},
};
testClient.httpBackend.when("GET", "/sync").respond(200, syncData);
return testClient.flushSync();
}
describe("MatrixClient crypto", function() {
if (!CRYPTO_ENABLED) {
return;
}
beforeEach(async function() {
aliTestClient = new TestClient(aliUserId, aliDeviceId, aliAccessToken);
await aliTestClient.client.initCrypto();
bobTestClient = new TestClient(bobUserId, bobDeviceId, bobAccessToken);
await bobTestClient.client.initCrypto();
aliMessages = [];
bobMessages = [];
});
afterEach(function() {
aliTestClient.httpBackend.verifyNoOutstandingExpectation();
bobTestClient.httpBackend.verifyNoOutstandingExpectation();
return Promise.all([aliTestClient.stop(), bobTestClient.stop()]);
});
it("Bob uploads device keys", function() {
return Promise.resolve()
.then(bobUploadsDeviceKeys);
});
it("Ali downloads Bobs device keys", function() {
return Promise.resolve()
.then(bobUploadsDeviceKeys)
.then(aliDownloadsKeys);
});
it("Ali gets keys with an invalid signature", function() {
return Promise.resolve()
.then(bobUploadsDeviceKeys)
.then(function() {
// tamper bob's keys
const bobDeviceKeys = bobTestClient.deviceKeys;
expect(bobDeviceKeys.keys["curve25519:" + bobDeviceId]).toBeTruthy();
bobDeviceKeys.keys["curve25519:" + bobDeviceId] += "abc";
return Promise.all([
aliTestClient.client.downloadKeys([bobUserId]),
expectAliQueryKeys(),
]);
}).then(function() {
return aliTestClient.client.getStoredDevicesForUser(bobUserId);
}).then((devices) => {
// should get an empty list
expect(devices).toEqual([]);
});
});
it("Ali gets keys with an incorrect userId", function() {
const eveUserId = "@eve:localhost";
const bobDeviceKeys = {
algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'],
device_id: 'bvcxz',
keys: {
'ed25519:bvcxz': 'pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q',
'curve25519:bvcxz': '7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ',
},
user_id: '@eve:localhost',
signatures: {
'@eve:localhost': {
'ed25519:bvcxz': 'CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG' +
'0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg',
},
},
};
const bobKeys = {};
bobKeys[bobDeviceId] = bobDeviceKeys;
aliTestClient.httpBackend.when(
"POST", "/keys/query",
).respond(200, function(path, content) {
const result = {};
result[bobUserId] = bobKeys;
return { device_keys: result };
});
return Promise.all([
aliTestClient.client.downloadKeys([bobUserId, eveUserId]),
aliTestClient.httpBackend.flush("/keys/query", 1),
]).then(function() {
return Promise.all([
aliTestClient.client.getStoredDevicesForUser(bobUserId),
aliTestClient.client.getStoredDevicesForUser(eveUserId),
]);
}).then(([bobDevices, eveDevices]) => {
// should get an empty list
expect(bobDevices).toEqual([]);
expect(eveDevices).toEqual([]);
});
});
it("Ali gets keys with an incorrect deviceId", function() {
const bobDeviceKeys = {
algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'],
device_id: 'bad_device',
keys: {
'ed25519:bad_device': 'e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0',
'curve25519:bad_device': 'YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc',
},
user_id: '@bob:localhost',
signatures: {
'@bob:localhost': {
'ed25519:bad_device': 'fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A' +
'me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ',
},
},
};
const bobKeys = {};
bobKeys[bobDeviceId] = bobDeviceKeys;
aliTestClient.httpBackend.when(
"POST", "/keys/query",
).respond(200, function(path, content) {
const result = {};
result[bobUserId] = bobKeys;
return { device_keys: result };
});
return Promise.all([
aliTestClient.client.downloadKeys([bobUserId]),
aliTestClient.httpBackend.flush("/keys/query", 1),
]).then(function() {
return aliTestClient.client.getStoredDevicesForUser(bobUserId);
}).then((devices) => {
// should get an empty list
expect(devices).toEqual([]);
});
});
it("Bob starts his client and uploads device keys and one-time keys", function() {
return Promise.resolve()
.then(() => bobTestClient.start())
.then(() => bobTestClient.awaitOneTimeKeyUpload())
.then((keys) => {
expect(Object.keys(keys).length).toEqual(5);
expect(Object.keys(bobTestClient.deviceKeys).length).not.toEqual(0);
});
});
it("Ali sends a message", function() {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => bobTestClient.start())
.then(() => firstSync(aliTestClient))
.then(aliEnablesEncryption)
.then(aliSendsFirstMessage);
});
it("Bob receives a message", function() {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => bobTestClient.start())
.then(() => firstSync(aliTestClient))
.then(aliEnablesEncryption)
.then(aliSendsFirstMessage)
.then(bobRecvMessage);
});
it("Bob receives a message with a bogus sender", function() {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => bobTestClient.start())
.then(() => firstSync(aliTestClient))
.then(aliEnablesEncryption)
.then(aliSendsFirstMessage)
.then(function() {
const message = aliMessages.shift();
const syncData = {
next_batch: "x",
rooms: {
join: {
},
},
};
syncData.rooms.join[roomId] = {
timeline: {
events: [
testUtils.mkEvent({
type: "m.room.encrypted",
room: roomId,
content: message,
sender: "@bogus:sender",
}),
],
},
};
bobTestClient.httpBackend.when("GET", "/sync").respond(200, syncData);
const eventPromise = new Promise((resolve, reject) => {
const onEvent = function(event) {
logger.log(bobUserId + " received event",
event);
resolve(event);
};
bobTestClient.client.once("event", onEvent);
});
bobTestClient.httpBackend.flush();
return eventPromise;
}).then((event) => {
expect(event.isEncrypted()).toBeTruthy();
// it may still be being decrypted
return testUtils.awaitDecryption(event);
}).then((event) => {
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().msgtype).toEqual("m.bad.encrypted");
});
});
it("Ali blocks Bob's device", function() {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => bobTestClient.start())
.then(() => firstSync(aliTestClient))
.then(aliEnablesEncryption)
.then(aliDownloadsKeys)
.then(function() {
aliTestClient.client.setDeviceBlocked(bobUserId, bobDeviceId, true);
const p1 = sendMessage(aliTestClient.client);
const p2 = expectSendMessageRequest(aliTestClient.httpBackend)
.then(function(sentContent) {
// no unblocked devices, so the ciphertext should be empty
expect(sentContent.ciphertext).toEqual({});
});
return Promise.all([p1, p2]);
});
});
it("Bob receives two pre-key messages", function() {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => bobTestClient.start())
.then(() => firstSync(aliTestClient))
.then(aliEnablesEncryption)
.then(aliSendsFirstMessage)
.then(bobRecvMessage)
.then(aliSendsMessage)
.then(bobRecvMessage);
});
it("Bob replies to the message", function() {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
bobTestClient.expectKeyQuery({ device_keys: { [bobUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => bobTestClient.start())
.then(() => firstSync(aliTestClient))
.then(() => firstSync(bobTestClient))
.then(aliEnablesEncryption)
.then(aliSendsFirstMessage)
.then(bobRecvMessage)
.then(bobEnablesEncryption)
.then(bobSendsReplyMessage).then(function(ciphertext) {
expect(ciphertext.type).toEqual(1, "Unexpected cipghertext type.");
}).then(aliRecvMessage);
});
it("Ali does a key query when encryption is enabled", function() {
// enabling encryption in the room should make alice download devices
// for both members.
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} } });
return Promise.resolve()
.then(() => aliTestClient.start())
.then(() => firstSync(aliTestClient))
.then(() => {
const syncData = {
next_batch: '2',
rooms: {
join: {},
},
};
syncData.rooms.join[roomId] = {
state: {
events: [
testUtils.mkEvent({
type: 'm.room.encryption',
skey: '',
content: {
algorithm: 'm.olm.v1.curve25519-aes-sha2',
},
}),
],
},
};
aliTestClient.httpBackend.when('GET', '/sync').respond(
200, syncData);
return aliTestClient.httpBackend.flush('/sync', 1);
}).then(() => {
aliTestClient.expectKeyQuery({
device_keys: {
[bobUserId]: {},
},
});
return aliTestClient.httpBackend.flushAllExpected();
});
});
it("Upload new oneTimeKeys based on a /sync request - no count-asking", function() {
// Send a response which causes a key upload
const httpBackend = aliTestClient.httpBackend;
const syncDataEmpty = {
next_batch: "a",
device_one_time_keys_count: {
signed_curve25519: 0,
},
};
// enqueue expectations:
// * Sync with empty one_time_keys => upload keys
return Promise.resolve()
.then(() => {
logger.log(aliTestClient + ': starting');
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
aliTestClient.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
httpBackend.when("GET", "/sync").respond(200, syncDataEmpty);
aliTestClient.client.startClient({});
return httpBackend.flushAllExpected().then(() => {
logger.log(aliTestClient + ': started');
});
})
.then(() => httpBackend.when("POST", "/keys/upload")
.respond(200, (path, content) => {
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
expect(Object.keys(content.one_time_keys).length)
.toBeGreaterThanOrEqual(1);
logger.log('received %i one-time keys',
Object.keys(content.one_time_keys).length);
// cancel futher calls by telling the client
// we have more than we need
return {
one_time_key_counts: {
signed_curve25519: 70,
},
};
}))
.then(() => httpBackend.flushAllExpected());
});
});
+676
View File
@@ -0,0 +1,676 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* This file consists of a set of integration tests which try to simulate
* communication via an Olm-encrypted room between two users, Alice and Bob.
*
* Note that megolm (group) conversation is not tested here.
*
* See also `megolm.spec.js`.
*/
// load olm before the sdk if possible
import '../olm-loader';
import { logger } from '../../src/logger';
import * as testUtils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { CRYPTO_ENABLED } from "../../src/client";
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix";
let aliTestClient: TestClient;
const roomId = "!room:localhost";
const aliUserId = "@ali:localhost";
const aliDeviceId = "zxcvb";
const aliAccessToken = "aseukfgwef";
let bobTestClient: TestClient;
const bobUserId = "@bob:localhost";
const bobDeviceId = "bvcxz";
const bobAccessToken = "fewgfkuesa";
let aliMessages: IContent[];
let bobMessages: IContent[];
// IMessage isn't exported by src/crypto/algorithms/olm.ts
interface OlmPayload {
type: number;
body: string;
}
async function bobUploadsDeviceKeys(): Promise<void> {
bobTestClient.expectDeviceKeyUpload();
await Promise.all([
bobTestClient.client.uploadKeys(),
bobTestClient.httpBackend.flushAllExpected(),
]);
expect(Object.keys(bobTestClient.deviceKeys).length).not.toEqual(0);
}
/**
* Set an expectation that querier will query uploader's keys; then flush the http request.
*
* @return {promise} resolves once the http request has completed.
*/
function expectQueryKeys(querier: TestClient, uploader: TestClient): Promise<number> {
// can't query keys before bob has uploaded them
expect(uploader.deviceKeys).toBeTruthy();
const uploaderKeys = {};
uploaderKeys[uploader.deviceId] = uploader.deviceKeys;
querier.httpBackend.when("POST", "/keys/query")
.respond(200, function(_path, content) {
expect(content.device_keys[uploader.userId]).toEqual([]);
const result = {};
result[uploader.userId] = uploaderKeys;
return { device_keys: result };
});
return querier.httpBackend.flush("/keys/query", 1);
}
const expectAliQueryKeys = () => expectQueryKeys(aliTestClient, bobTestClient);
const expectBobQueryKeys = () => expectQueryKeys(bobTestClient, aliTestClient);
/**
* Set an expectation that ali will claim one of bob's keys; then flush the http request.
*
* @return {promise} resolves once the http request has completed.
*/
async function expectAliClaimKeys(): Promise<void> {
const keys = await bobTestClient.awaitOneTimeKeyUpload();
aliTestClient.httpBackend.when(
"POST", "/keys/claim",
).respond(200, function(_path, content) {
const claimType = content.one_time_keys[bobUserId][bobDeviceId];
expect(claimType).toEqual("signed_curve25519");
let keyId = null;
for (keyId in keys) {
if (bobTestClient.oneTimeKeys.hasOwnProperty(keyId)) {
if (keyId.indexOf(claimType + ":") === 0) {
break;
}
}
}
const result = {};
result[bobUserId] = {};
result[bobUserId][bobDeviceId] = {};
result[bobUserId][bobDeviceId][keyId] = keys[keyId];
return { one_time_keys: result };
});
// it can take a while to process the key query, so give it some extra
// time, and make sure the claim actually happens rather than ploughing on
// confusingly.
const r = await aliTestClient.httpBackend.flush("/keys/claim", 1, 500);
expect(r).toEqual(1);
}
async function aliDownloadsKeys(): Promise<void> {
// can't query keys before bob has uploaded them
expect(bobTestClient.getSigningKey()).toBeTruthy();
const p1 = async () => {
await aliTestClient.client.downloadKeys([bobUserId]);
const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId);
expect(devices.length).toEqual(1);
expect(devices[0].deviceId).toEqual("bvcxz");
};
const p2 = expectAliQueryKeys;
// check that the localStorage is updated as we expect (not sure this is
// an integration test, but meh)
await Promise.all([p1(), p2()]);
await aliTestClient.client.crypto.deviceList.saveIfDirty();
// @ts-ignore - protected
aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data.devices[bobUserId];
expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys);
expect(devices[bobDeviceId].verified).
toBe(0); // DeviceVerification.UNVERIFIED
});
}
async function clientEnablesEncryption(client: MatrixClient): Promise<void> {
await client.setRoomEncryption(roomId, {
algorithm: "m.olm.v1.curve25519-aes-sha2",
});
expect(client.isRoomEncrypted(roomId)).toBeTruthy();
}
const aliEnablesEncryption = () => clientEnablesEncryption(aliTestClient.client);
const bobEnablesEncryption = () => clientEnablesEncryption(bobTestClient.client);
/**
* Ali sends a message, first claiming e2e keys. Set the expectations and
* check the results.
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
async function aliSendsFirstMessage(): Promise<OlmPayload> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, ciphertext] = await Promise.all([
sendMessage(aliTestClient.client),
expectAliQueryKeys()
.then(expectAliClaimKeys)
.then(expectAliSendMessageRequest),
]);
return ciphertext;
}
/**
* Ali sends a message without first claiming e2e keys. Set the expectations
* and check the results.
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
async function aliSendsMessage(): Promise<OlmPayload> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, ciphertext] = await Promise.all([
sendMessage(aliTestClient.client),
expectAliSendMessageRequest(),
]);
return ciphertext;
}
/**
* Bob sends a message, first querying (but not claiming) e2e keys. Set the
* expectations and check the results.
*
* @return {promise} which resolves to the ciphertext for Ali's device.
*/
async function bobSendsReplyMessage(): Promise<OlmPayload> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, ciphertext] = await Promise.all([
sendMessage(bobTestClient.client),
expectBobQueryKeys()
.then(expectBobSendMessageRequest),
]);
return ciphertext;
}
/**
* Set an expectation that Ali will send a message, and flush the request
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
async function expectAliSendMessageRequest(): Promise<OlmPayload> {
const content = await expectSendMessageRequest(aliTestClient.httpBackend);
aliMessages.push(content);
expect(Object.keys(content.ciphertext)).toEqual([bobTestClient.getDeviceKey()]);
const ciphertext = content.ciphertext[bobTestClient.getDeviceKey()];
expect(ciphertext).toBeTruthy();
return ciphertext;
}
/**
* Set an expectation that Bob will send a message, and flush the request
*
* @return {promise} which resolves to the ciphertext for Bob's device.
*/
async function expectBobSendMessageRequest(): Promise<OlmPayload> {
const content = await expectSendMessageRequest(bobTestClient.httpBackend);
bobMessages.push(content);
const aliKeyId = "curve25519:" + aliDeviceId;
const aliDeviceCurve25519Key = aliTestClient.deviceKeys.keys[aliKeyId];
expect(Object.keys(content.ciphertext)).toEqual([aliDeviceCurve25519Key]);
const ciphertext = content.ciphertext[aliDeviceCurve25519Key];
expect(ciphertext).toBeTruthy();
return ciphertext;
}
function sendMessage(client: MatrixClient): Promise<ISendEventResponse> {
return client.sendMessage(
roomId, { msgtype: "m.text", body: "Hello, World" },
);
}
async function expectSendMessageRequest(httpBackend: TestClient["httpBackend"]): Promise<IContent> {
const path = "/send/m.room.encrypted/";
const prom = new Promise((resolve) => {
httpBackend.when("PUT", path).respond(200, function(_path, content) {
resolve(content);
return {
event_id: "asdfgh",
};
});
});
// it can take a while to process the key query
await httpBackend.flush(path, 1);
return prom;
}
function aliRecvMessage(): Promise<void> {
const message = bobMessages.shift();
return recvMessage(
aliTestClient.httpBackend, aliTestClient.client, bobUserId, message,
);
}
function bobRecvMessage(): Promise<void> {
const message = aliMessages.shift();
return recvMessage(
bobTestClient.httpBackend, bobTestClient.client, aliUserId, message,
);
}
async function recvMessage(
httpBackend: TestClient["httpBackend"],
client: MatrixClient,
sender: string,
message: IContent,
): Promise<void> {
const syncData = {
next_batch: "x",
rooms: {
join: {
},
},
};
syncData.rooms.join[roomId] = {
timeline: {
events: [
testUtils.mkEvent({
type: "m.room.encrypted",
room: roomId,
content: message,
sender: sender,
}),
],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
const eventPromise = new Promise<MatrixEvent>((resolve) => {
const onEvent = function(event: MatrixEvent) {
// ignore the m.room.member events
if (event.getType() == "m.room.member") {
return;
}
logger.log(client.credentials.userId + " received event",
event);
client.removeListener(ClientEvent.Event, onEvent);
resolve(event);
};
client.on(ClientEvent.Event, onEvent);
});
await httpBackend.flushAllExpected();
const preDecryptionEvent = await eventPromise;
expect(preDecryptionEvent.isEncrypted()).toBeTruthy();
// it may still be being decrypted
const event = await testUtils.awaitDecryption(preDecryptionEvent);
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent()).toMatchObject({
msgtype: "m.text",
body: "Hello, World",
});
expect(event.isEncrypted()).toBeTruthy();
}
/**
* Send an initial sync response to the client (which just includes the member
* list for our test room).
*
* @param {TestClient} testClient
* @returns {Promise} which resolves when the sync has been flushed.
*/
function firstSync(testClient: TestClient): Promise<void> {
// send a sync response including our test room.
const syncData = {
next_batch: "x",
rooms: {
join: { },
},
};
syncData.rooms.join[roomId] = {
state: {
events: [
testUtils.mkMembership({
mship: "join",
user: aliUserId,
}),
testUtils.mkMembership({
mship: "join",
user: bobUserId,
}),
],
},
timeline: {
events: [],
},
};
testClient.httpBackend.when("GET", "/sync").respond(200, syncData);
return testClient.flushSync();
}
describe("MatrixClient crypto", () => {
if (!CRYPTO_ENABLED) {
return;
}
beforeEach(async () => {
aliTestClient = new TestClient(aliUserId, aliDeviceId, aliAccessToken);
await aliTestClient.client.initCrypto();
bobTestClient = new TestClient(bobUserId, bobDeviceId, bobAccessToken);
await bobTestClient.client.initCrypto();
aliMessages = [];
bobMessages = [];
});
afterEach(() => {
aliTestClient.httpBackend.verifyNoOutstandingExpectation();
bobTestClient.httpBackend.verifyNoOutstandingExpectation();
return Promise.all([aliTestClient.stop(), bobTestClient.stop()]);
});
it("Bob uploads device keys", bobUploadsDeviceKeys);
it("Ali downloads Bobs device keys", async () => {
await bobUploadsDeviceKeys();
await aliDownloadsKeys();
});
it("Ali gets keys with an invalid signature", async () => {
await bobUploadsDeviceKeys();
// tamper bob's keys
const bobDeviceKeys = bobTestClient.deviceKeys;
expect(bobDeviceKeys.keys["curve25519:" + bobDeviceId]).toBeTruthy();
bobDeviceKeys.keys["curve25519:" + bobDeviceId] += "abc";
await Promise.all([
aliTestClient.client.downloadKeys([bobUserId]),
expectAliQueryKeys(),
]);
const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId);
// should get an empty list
expect(devices).toEqual([]);
});
it("Ali gets keys with an incorrect userId", async () => {
const eveUserId = "@eve:localhost";
const bobDeviceKeys = {
algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'],
device_id: 'bvcxz',
keys: {
'ed25519:bvcxz': 'pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q',
'curve25519:bvcxz': '7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ',
},
user_id: '@eve:localhost',
signatures: {
'@eve:localhost': {
'ed25519:bvcxz': 'CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG' +
'0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg',
},
},
};
const bobKeys = {};
bobKeys[bobDeviceId] = bobDeviceKeys;
aliTestClient.httpBackend.when(
"POST", "/keys/query",
).respond(200, { device_keys: { [bobUserId]: bobKeys } });
await Promise.all([
aliTestClient.client.downloadKeys([bobUserId, eveUserId]),
aliTestClient.httpBackend.flush("/keys/query", 1),
]);
const [bobDevices, eveDevices] = await Promise.all([
aliTestClient.client.getStoredDevicesForUser(bobUserId),
aliTestClient.client.getStoredDevicesForUser(eveUserId),
]);
// should get an empty list
expect(bobDevices).toEqual([]);
expect(eveDevices).toEqual([]);
});
it("Ali gets keys with an incorrect deviceId", async () => {
const bobDeviceKeys = {
algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'],
device_id: 'bad_device',
keys: {
'ed25519:bad_device': 'e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0',
'curve25519:bad_device': 'YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc',
},
user_id: '@bob:localhost',
signatures: {
'@bob:localhost': {
'ed25519:bad_device': 'fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A' +
'me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ',
},
},
};
const bobKeys = {};
bobKeys[bobDeviceId] = bobDeviceKeys;
aliTestClient.httpBackend.when(
"POST", "/keys/query",
).respond(200, { device_keys: { [bobUserId]: bobKeys } });
await Promise.all([
aliTestClient.client.downloadKeys([bobUserId]),
aliTestClient.httpBackend.flush("/keys/query", 1),
]);
const devices = aliTestClient.client.getStoredDevicesForUser(bobUserId);
// should get an empty list
expect(devices).toEqual([]);
});
it("Bob starts his client and uploads device keys and one-time keys", async () => {
await bobTestClient.start();
const keys = await bobTestClient.awaitOneTimeKeyUpload();
expect(Object.keys(keys).length).toEqual(5);
expect(Object.keys(bobTestClient.deviceKeys).length).not.toEqual(0);
});
it("Ali sends a message", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
});
it("Bob receives a message", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
await bobRecvMessage();
});
it("Bob receives a message with a bogus sender", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
const message = aliMessages.shift();
const syncData = {
next_batch: "x",
rooms: {
join: {
},
},
};
syncData.rooms.join[roomId] = {
timeline: {
events: [
testUtils.mkEvent({
type: "m.room.encrypted",
room: roomId,
content: message,
sender: "@bogus:sender",
}),
],
},
};
bobTestClient.httpBackend.when("GET", "/sync").respond(200, syncData);
const eventPromise = new Promise<MatrixEvent>((resolve) => {
const onEvent = function(event: MatrixEvent) {
logger.log(bobUserId + " received event", event);
resolve(event);
};
bobTestClient.client.once(ClientEvent.Event, onEvent);
});
await bobTestClient.httpBackend.flushAllExpected();
const preDecryptionEvent = await eventPromise;
expect(preDecryptionEvent.isEncrypted()).toBeTruthy();
// it may still be being decrypted
const event = await testUtils.awaitDecryption(preDecryptionEvent);
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().msgtype).toEqual("m.bad.encrypted");
});
it("Ali blocks Bob's device", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliDownloadsKeys();
aliTestClient.client.setDeviceBlocked(bobUserId, bobDeviceId, true);
const p1 = sendMessage(aliTestClient.client);
const p2 = expectSendMessageRequest(aliTestClient.httpBackend)
.then(function(sentContent) {
// no unblocked devices, so the ciphertext should be empty
expect(sentContent.ciphertext).toEqual({});
});
await Promise.all([p1, p2]);
});
it("Bob receives two pre-key messages", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
await bobRecvMessage();
await aliSendsMessage();
await bobRecvMessage();
});
it("Bob replies to the message", async () => {
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
bobTestClient.expectKeyQuery({ device_keys: { [bobUserId]: {} }, failures: {} });
await aliTestClient.start();
await bobTestClient.start();
await firstSync(aliTestClient);
await firstSync(bobTestClient);
await aliEnablesEncryption();
await aliSendsFirstMessage();
await bobRecvMessage();
await bobEnablesEncryption();
const ciphertext = await bobSendsReplyMessage();
expect(ciphertext.type).toEqual(1);
await aliRecvMessage();
});
it("Ali does a key query when encryption is enabled", async () => {
// enabling encryption in the room should make alice download devices
// for both members.
aliTestClient.expectKeyQuery({ device_keys: { [aliUserId]: {} }, failures: {} });
await aliTestClient.start();
await firstSync(aliTestClient);
const syncData = {
next_batch: '2',
rooms: {
join: {},
},
};
syncData.rooms.join[roomId] = {
state: {
events: [
testUtils.mkEvent({
type: 'm.room.encryption',
skey: '',
content: {
algorithm: 'm.olm.v1.curve25519-aes-sha2',
},
}),
],
},
};
aliTestClient.httpBackend.when('GET', '/sync').respond(
200, syncData);
await aliTestClient.httpBackend.flush('/sync', 1);
aliTestClient.expectKeyQuery({
device_keys: {
[bobUserId]: {},
},
failures: {},
});
await aliTestClient.httpBackend.flushAllExpected();
});
it("Upload new oneTimeKeys based on a /sync request - no count-asking", async () => {
// Send a response which causes a key upload
const httpBackend = aliTestClient.httpBackend;
const syncDataEmpty = {
next_batch: "a",
device_one_time_keys_count: {
signed_curve25519: 0,
},
};
// enqueue expectations:
// * Sync with empty one_time_keys => upload keys
logger.log(aliTestClient + ': starting');
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
aliTestClient.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
httpBackend.when("GET", "/sync").respond(200, syncDataEmpty);
await Promise.all([
aliTestClient.client.startClient({}),
httpBackend.flushAllExpected(),
]);
logger.log(aliTestClient + ': started');
httpBackend.when("POST", "/keys/upload")
.respond(200, (_path, content) => {
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
expect(Object.keys(content.one_time_keys).length).toBeGreaterThanOrEqual(1);
logger.log('received %i one-time keys', Object.keys(content.one_time_keys).length);
// cancel futher calls by telling the client
// we have more than we need
return {
one_time_key_counts: {
signed_curve25519: 70,
},
};
});
await httpBackend.flushAllExpected();
});
});
@@ -1,5 +1,21 @@
/*
Copyright 2022 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 * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/matrix";
import { ClientEvent, EventTimeline, Filter, IEvent, MatrixClient, MatrixEvent, Room } from "../../src/matrix";
import { logger } from "../../src/logger";
import { TestClient } from "../TestClient";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
@@ -10,8 +26,14 @@ const accessToken = "aseukfgwef";
const roomId = "!foo:bar";
const otherUserId = "@bob:localhost";
const withoutRoomId = (e: Partial<IEvent>): Partial<IEvent> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { room_id: _, ...copy } = e;
return copy;
};
const USER_MEMBERSHIP_EVENT = utils.mkMembership({
room: roomId, mship: "join", user: userId, name: userName,
room: roomId, mship: "join", user: userId, name: userName, event: false,
});
const ROOM_NAME_EVENT = utils.mkEvent({
@@ -19,34 +41,37 @@ const ROOM_NAME_EVENT = utils.mkEvent({
content: {
name: "Old room name",
},
event: false,
});
const INITIAL_SYNC_DATA = {
next_batch: "s_5_3",
rooms: {
join: {
"!foo:bar": { // roomId
[roomId]: {
timeline: {
events: [
utils.mkMessage({
room: roomId, user: otherUserId, msg: "hello",
user: otherUserId, msg: "hello", event: false,
}),
],
prev_batch: "f_1_1",
},
state: {
events: [
ROOM_NAME_EVENT,
withoutRoomId(ROOM_NAME_EVENT),
utils.mkMembership({
room: roomId, mship: "join",
mship: "join",
user: otherUserId, name: "Bob",
event: false,
}),
USER_MEMBERSHIP_EVENT,
withoutRoomId(USER_MEMBERSHIP_EVENT),
utils.mkEvent({
type: "m.room.create", room: roomId, user: userId,
type: "m.room.create", user: userId,
content: {
creator: userId,
},
event: false,
}),
],
},
@@ -57,23 +82,37 @@ const INITIAL_SYNC_DATA = {
const EVENTS = [
utils.mkMessage({
room: roomId, user: userId, msg: "we",
room: roomId, user: userId, msg: "we", event: false,
}),
utils.mkMessage({
room: roomId, user: userId, msg: "could",
room: roomId, user: userId, msg: "could", event: false,
}),
utils.mkMessage({
room: roomId, user: userId, msg: "be",
room: roomId, user: userId, msg: "be", event: false,
}),
utils.mkMessage({
room: roomId, user: userId, msg: "heroes",
room: roomId, user: userId, msg: "heroes", event: false,
}),
];
const THREAD_ROOT = utils.mkMessage({
const THREAD_ROOT = utils.mkEvent({
room: roomId,
user: userId,
msg: "thread root",
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 THREAD_REPLY = utils.mkEvent({
@@ -89,10 +128,25 @@ const THREAD_REPLY = utils.mkEvent({
event_id: THREAD_ROOT.event_id,
},
},
event: false,
});
THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY;
const SYNC_THREAD_ROOT = withoutRoomId(THREAD_ROOT);
const SYNC_THREAD_REPLY = withoutRoomId(THREAD_REPLY);
SYNC_THREAD_ROOT.unsigned = {
"m.relations": {
"io.element.thread": {
"latest_event": SYNC_THREAD_REPLY,
"count": 1,
"current_user_participated": true,
},
},
};
// start the client, and wait for it to initialise
function startClient(httpBackend, client) {
function startClient(httpBackend: TestClient["httpBackend"], client: MatrixClient) {
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
@@ -101,8 +155,8 @@ function startClient(httpBackend, client) {
client.startClient();
// set up a promise which will resolve once the client is initialised
const prom = new Promise((resolve) => {
client.on("sync", function(state) {
const prom = new Promise<void>((resolve) => {
client.on(ClientEvent.Sync, function(state) {
logger.log("sync", state);
if (state != "SYNCING") {
return;
@@ -118,8 +172,8 @@ function startClient(httpBackend, client) {
}
describe("getEventTimeline support", function() {
let httpBackend;
let client;
let httpBackend: TestClient["httpBackend"];
let client: MatrixClient;
beforeEach(function() {
const testClient = new TestClient(userId, "DEVICE", accessToken);
@@ -162,7 +216,7 @@ describe("getEventTimeline support", function() {
it("scrollback should be able to scroll back to before a gappy /sync", function() {
// need a client with timelineSupport disabled to make this work
let room;
let room: Room;
return startClient(httpBackend, client).then(function() {
room = client.getRoom(roomId);
@@ -174,7 +228,7 @@ describe("getEventTimeline support", function() {
"!foo:bar": {
timeline: {
events: [
EVENTS[0],
withoutRoomId(EVENTS[0]),
],
prev_batch: "f_1_1",
},
@@ -190,7 +244,7 @@ describe("getEventTimeline support", function() {
"!foo:bar": {
timeline: {
events: [
EVENTS[1],
withoutRoomId(EVENTS[1]),
],
limited: true,
prev_batch: "f_1_2",
@@ -225,8 +279,8 @@ describe("getEventTimeline support", function() {
});
describe("MatrixClient event timelines", function() {
let client = null;
let httpBackend = null;
let client: MatrixClient;
let httpBackend: TestClient["httpBackend"];
beforeEach(function() {
const testClient = new TestClient(
@@ -245,7 +299,7 @@ describe("MatrixClient event timelines", function() {
afterEach(function() {
httpBackend.verifyNoOutstandingExpectation();
client.stopClient();
Thread.setServerSideSupport(false);
Thread.setServerSideSupport(false, false);
});
describe("getEventTimeline", function() {
@@ -293,7 +347,7 @@ describe("MatrixClient event timelines", function() {
"!foo:bar": {
timeline: {
events: [
EVENTS[0],
withoutRoomId(EVENTS[0]),
],
prev_batch: "f_1_2",
},
@@ -328,7 +382,7 @@ describe("MatrixClient event timelines", function() {
"!foo:bar": {
timeline: {
events: [
EVENTS[3],
withoutRoomId(EVENTS[3]),
],
prev_batch: "f_1_2",
},
@@ -351,7 +405,7 @@ describe("MatrixClient event timelines", function() {
});
const prom = new Promise((resolve, reject) => {
client.on("sync", function() {
client.on(ClientEvent.Sync, function() {
client.getEventTimeline(timelineSet, EVENTS[2].event_id,
).then(function(tl) {
expect(tl.getEvents().length).toEqual(4);
@@ -496,10 +550,13 @@ describe("MatrixClient event timelines", function() {
});
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
Thread.setServerSideSupport(true);
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true, false);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false);
const timelineSet = thread.timelineSet;
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
@@ -525,8 +582,7 @@ describe("MatrixClient event timelines", function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
next_batch: "next_batch_token0",
prev_batch: "prev_batch_token0",
// no next batch as this is the oldest end of the timeline
};
});
@@ -535,8 +591,193 @@ describe("MatrixClient event timelines", function() {
const timeline = await timelinePromise;
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id));
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id));
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy();
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy();
});
it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true, false);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_ROOT,
events_after: [],
end: "end_token0",
state: [],
};
});
const [timeline] = await Promise.all([
client.getEventTimeline(timelineSet, THREAD_ROOT.event_id),
httpBackend.flushAllExpected(),
]);
expect(timeline).not.toBe(thread.liveTimeline);
expect(timelineSet.getTimelines()).toContain(timeline);
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy();
});
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true, false);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false);
const timelineSet = thread.timelineSet;
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id)).resolves.toBeUndefined(),
httpBackend.flushAllExpected(),
]);
});
it("should return undefined when event is within a thread but timelineSet is not", () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true, false);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id)).resolves.toBeUndefined(),
httpBackend.flushAllExpected(),
]);
});
it("should should add lazy loading filter when requested", async () => {
// @ts-ignore
client.clientOpts.lazyLoadMembers = true;
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id));
req.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
req.check((request) => {
expect(request.queryParams.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER));
});
await Promise.all([
client.getEventTimeline(timelineSet, EVENTS[0].event_id),
httpBackend.flushAllExpected(),
]);
});
});
describe("getLatestTimeline", function() {
it("should create a new timeline for new events", function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
const latestMessageId = 'event1:bar';
httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.respond(200, function() {
return {
chunk: [{
event_id: latestMessageId,
}],
};
});
httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`)
.respond(200, function() {
return {
start: "start_token",
events_before: [EVENTS[1], EVENTS[0]],
event: EVENTS[2],
events_after: [EVENTS[3]],
state: [
ROOM_NAME_EVENT,
USER_MEMBERSHIP_EVENT,
],
end: "end_token",
};
});
return Promise.all([
client.getLatestTimeline(timelineSet).then(function(tl) {
// Instead of this assertion logic, we could just add a spy
// for `getEventTimeline` and make sure it's called with the
// correct parameters. This doesn't feel too bad to make sure
// `getLatestTimeline` is doing the right thing though.
expect(tl.getEvents().length).toEqual(4);
for (let i = 0; i < 4; i++) {
expect(tl.getEvents()[i].event).toEqual(EVENTS[i]);
expect(tl.getEvents()[i].sender.name).toEqual(userName);
}
expect(tl.getPaginationToken(EventTimeline.BACKWARDS))
.toEqual("start_token");
expect(tl.getPaginationToken(EventTimeline.FORWARDS))
.toEqual("end_token");
}),
httpBackend.flushAllExpected(),
]);
});
it("should throw error when /messages does not return a message", () => {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.respond(200, () => {
return {
chunk: [
// No messages to return
],
};
});
return Promise.all([
expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(),
httpBackend.flushAllExpected(),
]);
});
});
@@ -666,7 +907,7 @@ describe("MatrixClient event timelines", function() {
"!foo:bar": {
timeline: {
events: [
event,
withoutRoomId(event),
],
prev_batch: "f_1_1",
},
@@ -744,11 +985,10 @@ describe("MatrixClient event timelines", function() {
// a state event, followed by a redaction thereof
const event = utils.mkMembership({
room: roomId, mship: "join", user: otherUserId,
mship: "join", user: otherUserId,
});
const redaction = utils.mkEvent({
type: "m.room.redaction",
room_id: roomId,
sender: otherUserId,
content: {},
});
@@ -790,7 +1030,7 @@ describe("MatrixClient event timelines", function() {
timeline: {
events: [
utils.mkMessage({
room: roomId, user: otherUserId, msg: "world",
user: otherUserId, msg: "world",
}),
],
limited: true,
@@ -809,4 +1049,75 @@ describe("MatrixClient event timelines", function() {
expect(tl.getEvents().length).toEqual(1);
});
});
it("should re-insert room IDs for bundled thread relation events", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true, false);
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
SYNC_THREAD_ROOT,
],
prev_batch: "f_1_1",
},
},
},
},
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
const room = client.getRoom(roomId);
const thread = room.getThread(THREAD_ROOT.event_id);
const timelineSet = thread.timelineSet;
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id))
.respond(200, {
start: "start_token",
events_before: [],
event: THREAD_ROOT,
events_after: [],
state: [],
end: "end_token",
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20")
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
// no next batch as this is the oldest end of the timeline
};
});
await Promise.all([
client.getEventTimeline(timelineSet, THREAD_ROOT.event_id),
httpBackend.flushAllExpected(),
]);
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_5",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
SYNC_THREAD_REPLY,
],
prev_batch: "f_1_2",
},
},
},
},
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
expect(thread.liveTimeline.getEvents()[1].event).toEqual(THREAD_REPLY);
});
});
+255 -4
View File
@@ -1,3 +1,19 @@
/*
Copyright 2022 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 * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { MatrixEvent } from "../../src/models/event";
@@ -11,11 +27,19 @@ describe("MatrixClient", function() {
let store = null;
const userId = "@alice:localhost";
const accessToken = "aseukfgwef";
const idServerDomain = "identity.localhost"; // not a real server
const identityAccessToken = "woop-i-am-a-secret";
beforeEach(function() {
store = new MemoryStore();
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { store });
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, {
store,
identityServer: {
getAccessToken: () => Promise.resolve(identityAccessToken),
},
idBaseUrl: `https://${idServerDomain}`,
});
httpBackend = testClient.httpBackend;
client = testClient.client;
});
@@ -26,7 +50,7 @@ describe("MatrixClient", function() {
});
describe("uploadContent", function() {
const buf = new Buffer('hello world');
const buf = Buffer.from('hello world');
it("should upload the file", function() {
httpBackend.when(
"POST", "/_matrix/media/r0/upload",
@@ -458,6 +482,10 @@ describe("MatrixClient", function() {
return client.initCrypto();
});
afterEach(() => {
client.stopClient();
});
it("should do an HTTP request and then store the keys", function() {
const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78";
// ed25519key = client.getDeviceEd25519Key();
@@ -797,7 +825,7 @@ describe("MatrixClient", function() {
]);
});
it("sends reply to thread responses to thread timeline only", () => {
it("sends reply to thread responses to main timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
@@ -814,15 +842,238 @@ describe("MatrixClient", function() {
expect(timeline).toEqual([
threadRootEvent,
replyToThreadResponse,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
replyToThreadResponse,
]);
});
});
describe("getThirdpartyUser", () => {
it("should hit the expected API endpoint", async () => {
const response = [{
userid: "@Bob",
protocol: "irc",
fields: {},
}];
const prom = client.getThirdpartyUser("irc", {});
httpBackend.when("GET", "/thirdparty/user/irc").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getThirdpartyLocation", () => {
it("should hit the expected API endpoint", async () => {
const response = [{
alias: "#alias",
protocol: "irc",
fields: {},
}];
const prom = client.getThirdpartyLocation("irc", {});
httpBackend.when("GET", "/thirdparty/location/irc").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getPushers", () => {
it("should hit the expected API endpoint", async () => {
const response = {
pushers: [],
};
const prom = client.getPushers();
httpBackend.when("GET", "/pushers").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getKeyChanges", () => {
it("should hit the expected API endpoint", async () => {
const response = {
changed: [],
left: [],
};
const prom = client.getKeyChanges("old", "new");
httpBackend.when("GET", "/keys/changes").check((req) => {
expect(req.queryParams.from).toEqual("old");
expect(req.queryParams.to).toEqual("new");
}).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getDevices", () => {
it("should hit the expected API endpoint", async () => {
const response = {
devices: [],
};
const prom = client.getDevices();
httpBackend.when("GET", "/devices").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getDevice", () => {
it("should hit the expected API endpoint", async () => {
const response = {
device_id: "DEADBEEF",
display_name: "NotAPhone",
last_seen_ip: "127.0.0.1",
last_seen_ts: 1,
};
const prom = client.getDevice("DEADBEEF");
httpBackend.when("GET", "/devices/DEADBEEF").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getThreePids", () => {
it("should hit the expected API endpoint", async () => {
const response = {
threepids: [],
};
const prom = client.getThreePids();
httpBackend.when("GET", "/account/3pid").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("deleteAlias", () => {
it("should hit the expected API endpoint", async () => {
const response = {};
const prom = client.deleteAlias("#foo:bar");
httpBackend.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("deleteRoomTag", () => {
it("should hit the expected API endpoint", async () => {
const response = {};
const prom = client.deleteRoomTag("!roomId:server", "u.tag");
const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags/u.tag`;
httpBackend.when("DELETE", url).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getRoomTags", () => {
it("should hit the expected API endpoint", async () => {
const response = {
tags: {
"u.tag": {
order: 0.5,
},
},
};
const prom = client.getRoomTags("!roomId:server");
const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags`;
httpBackend.when("GET", url).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("requestRegisterEmailToken", () => {
it("should hit the expected API endpoint", async () => {
const response = {
sid: "random_sid",
submit_url: "https://foobar.matrix/_matrix/matrix",
};
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.6.0"],
});
const prom = client.requestRegisterEmailToken("bob@email", "secret", 1);
httpBackend.when("POST", "/register/email/requestToken").check(req => {
expect(req.data).toStrictEqual({
email: "bob@email",
client_secret: "secret",
send_attempt: 1,
});
}).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("inviteByThreePid", () => {
it("should supply an id_access_token", async () => {
const targetEmail = "gerald@example.org";
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.6.0"],
});
httpBackend.when("POST", "/invite").check(req => {
expect(req.data).toStrictEqual({
id_server: idServerDomain,
id_access_token: identityAccessToken,
medium: "email",
address: targetEmail,
});
}).respond(200, {});
const prom = client.inviteByThreePid("!room:example.org", "email", targetEmail);
await httpBackend.flush();
await prom; // returns empty object, so no validation needed
});
});
describe("createRoom", () => {
it("should populate id_access_token on 3pid invites", async () => {
const targetEmail = "gerald@example.org";
const response = {
room_id: "!room:localhost",
};
const input = {
invite_3pid: [{
// we intentionally exclude the access token here, so it can be populated for us
id_server: idServerDomain,
medium: "email",
address: targetEmail,
}],
};
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.6.0"],
});
httpBackend.when("POST", "/createRoom").check(req => {
expect(req.data).toMatchObject({
invite_3pid: expect.arrayContaining([{
...input.invite_3pid[0],
id_access_token: identityAccessToken,
}]),
});
expect(req.data.invite_3pid.length).toBe(1);
}).respond(200, response);
const prom = client.createRoom(input);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
});
function withThreadId(event, newThreadId) {
+6 -1
View File
@@ -8,7 +8,6 @@ import { MatrixError } from "../../src/http-api";
describe("MatrixClient opts", function() {
const baseUrl = "http://localhost.or.something";
let client = null;
let httpBackend = null;
const userId = "@alice:localhost";
const userB = "@bob:localhost";
@@ -65,6 +64,7 @@ describe("MatrixClient opts", function() {
});
describe("without opts.store", function() {
let client;
beforeEach(function() {
client = new MatrixClient({
request: httpBackend.requestFn,
@@ -124,6 +124,7 @@ describe("MatrixClient opts", function() {
});
describe("without opts.scheduler", function() {
let client;
beforeEach(function() {
client = new MatrixClient({
request: httpBackend.requestFn,
@@ -135,6 +136,10 @@ describe("MatrixClient opts", function() {
});
});
afterEach(function() {
client.stopClient();
});
it("shouldn't retry sending events", function(done) {
httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({
errcode: "M_SOMETHING",
+18 -2
View File
@@ -1,10 +1,26 @@
import { EventStatus, RoomEvent } from "../../src/matrix";
/*
Copyright 2022 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 { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix";
import { MatrixScheduler } from "../../src/scheduler";
import { Room } from "../../src/models/room";
import { TestClient } from "../TestClient";
describe("MatrixClient retrying", function() {
let client: TestClient = null;
let client: MatrixClient = null;
let httpBackend: TestClient["httpBackend"] = null;
let scheduler;
const userId = "@alice:localhost";
+269 -1
View File
@@ -1,5 +1,6 @@
import * as utils from "../test-utils/test-utils";
import { EventStatus } from "../../src/models/event";
import { RoomEvent } from "../../src";
import { TestClient } from "../TestClient";
describe("MatrixClient room timelines", function() {
@@ -579,7 +580,7 @@ describe("MatrixClient room timelines", function() {
});
});
it("should emit a 'Room.timelineReset' event", function() {
it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function() {
const eventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
@@ -608,4 +609,271 @@ describe("MatrixClient room timelines", function() {
});
});
});
describe('Refresh live timeline', () => {
const initialSyncEventData = [
utils.mkMessage({ user: userId, room: roomId }),
utils.mkMessage({ user: userId, room: roomId }),
utils.mkMessage({ user: userId, room: roomId }),
];
const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` +
`${encodeURIComponent(initialSyncEventData[2].event_id)}`;
const contextResponse = {
start: "start_token",
events_before: [initialSyncEventData[1], initialSyncEventData[0]],
event: initialSyncEventData[2],
events_after: [],
state: [
USER_MEMBERSHIP_EVENT,
],
end: "end_token",
};
let room;
beforeEach(async () => {
setNextSyncData(initialSyncEventData);
// Create a room from the sync
await Promise.all([
httpBackend.flushAllExpected(),
utils.syncPromise(client, 1),
]);
// Get the room after the first sync so the room is created
room = client.getRoom(roomId);
expect(room).toBeTruthy();
});
it('should clear and refresh messages in timeline', async () => {
// `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()`
// to construct a new timeline from.
httpBackend.when("GET", contextUrl)
.respond(200, function() {
// The timeline should be cleared at this point in the refresh
expect(room.timeline.length).toEqual(0);
return contextResponse;
});
// Refresh the timeline.
await Promise.all([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// Make sure the message are visible
const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents();
const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId());
expect(resultantEventIdsInTimeline).toEqual([
initialSyncEventData[0].event_id,
initialSyncEventData[1].event_id,
initialSyncEventData[2].event_id,
]);
});
it('Perfectly merges timelines if a sync finishes while refreshing the timeline', async () => {
// `/context` request for `refreshLiveTimeline()` ->
// `getEventTimeline()` to construct a new timeline from.
//
// We only resolve this request after we detect that the timeline
// was reset(when it goes blank) and force a sync to happen in the
// middle of all of this refresh timeline logic. We want to make
// sure the sync pagination still works as expected after messing
// the refresh timline logic messes with the pagination tokens.
httpBackend.when("GET", contextUrl)
.respond(200, () => {
// Now finally return and make the `/context` request respond
return contextResponse;
});
// Wait for the timeline to reset(when it goes blank) which means
// it's in the middle of the refrsh logic right before the
// `getEventTimeline()` -> `/context`. Then simulate a racey `/sync`
// to happen in the middle of all of this refresh timeline logic. We
// want to make sure the sync pagination still works as expected
// after messing the refresh timline logic messes with the
// pagination tokens.
//
// We define this here so the event listener is in place before we
// call `room.refreshLiveTimeline()`.
const racingSyncEventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => {
let eventFired = false;
// Throw a more descriptive error if this part of the test times out.
const failTimeout = setTimeout(() => {
if (eventFired) {
reject(new Error(
'TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make' +
'a `/sync` happen in time.',
));
} else {
reject(new Error(
'TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.',
));
}
}, 4000 /* FIXME: Is there a way to reference the current timeout of this test in Jest? */);
room.on(RoomEvent.TimelineReset, async () => {
try {
eventFired = true;
// The timeline should be cleared at this point in the refresh
expect(room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length).toEqual(0);
// Then make a `/sync` happen by sending a message and seeing that it
// shows up (simulate a /sync naturally racing with us).
setNextSyncData(racingSyncEventData);
httpBackend.when("GET", "/sync").respond(200, function() {
return NEXT_SYNC_DATA;
});
await Promise.all([
httpBackend.flush("/sync", 1),
utils.syncPromise(client, 1),
]);
// Make sure the timeline has the racey sync data
const afterRaceySyncTimelineEvents = room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents();
const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents
.map((event) => event.getId());
expect(afterRaceySyncTimelineEventIds).toEqual([
racingSyncEventData[0].event_id,
]);
clearTimeout(failTimeout);
resolve();
} catch (err) {
reject(err);
}
});
});
// Refresh the timeline. Just start the function, we will wait for
// it to finish after the racey sync.
const refreshLiveTimelinePromise = room.refreshLiveTimeline();
await waitForRaceySyncAfterResetPromise;
await Promise.all([
refreshLiveTimelinePromise,
// Then flush the remaining `/context` to left the refresh logic complete
httpBackend.flushAllExpected(),
]);
// Make sure sync pagination still works by seeing a new message show up
// after refreshing the timeline.
const afterRefreshEventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
setNextSyncData(afterRefreshEventData);
httpBackend.when("GET", "/sync").respond(200, function() {
return NEXT_SYNC_DATA;
});
await Promise.all([
httpBackend.flushAllExpected(),
utils.syncPromise(client, 1),
]);
// Make sure the timeline includes the the events from the `/sync`
// that raced and beat us in the middle of everything and the
// `/sync` after the refresh. Since the `/sync` beat us to create
// the timeline, `initialSyncEventData` won't be visible unless we
// paginate backwards with `/messages`.
const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents();
const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId());
expect(resultantEventIdsInTimeline).toEqual([
racingSyncEventData[0].event_id,
afterRefreshEventData[0].event_id,
]);
});
it('Timeline recovers after `/context` request to generate new timeline fails', async () => {
// `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()`
// to construct a new timeline from.
httpBackend.when("GET", contextUrl)
.respond(500, function() {
// The timeline should be cleared at this point in the refresh
expect(room.timeline.length).toEqual(0);
return {
errcode: 'TEST_FAKE_ERROR',
error: 'We purposely intercepted this /context request to make it fail ' +
'in order to test whether the refresh timeline code is resilient',
};
});
// Refresh the timeline and expect it to fail
const settledFailedRefreshPromises = await Promise.allSettled([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// We only expect `TEST_FAKE_ERROR` here. Anything else is
// unexpected and should fail the test.
if (settledFailedRefreshPromises[0].status === 'fulfilled') {
throw new Error('Expected the /context request to fail with a 500');
} else if (settledFailedRefreshPromises[0].reason.errcode !== 'TEST_FAKE_ERROR') {
throw settledFailedRefreshPromises[0].reason;
}
// The timeline will be empty after we refresh the timeline and fail
// to construct a new timeline.
expect(room.timeline.length).toEqual(0);
// `/messages` request for `refreshLiveTimeline()` ->
// `getLatestTimeline()` to construct a new timeline from.
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`)
.respond(200, function() {
return {
chunk: [{
// The latest message in the room
event_id: initialSyncEventData[2].event_id,
}],
};
});
// `/context` request for `refreshLiveTimeline()` ->
// `getLatestTimeline()` -> `getEventTimeline()` to construct a new
// timeline from.
httpBackend.when("GET", contextUrl)
.respond(200, function() {
// The timeline should be cleared at this point in the refresh
expect(room.timeline.length).toEqual(0);
return contextResponse;
});
// Refresh the timeline again but this time it should pass
await Promise.all([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// Make sure sync pagination still works by seeing a new message show up
// after refreshing the timeline.
const afterRefreshEventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
setNextSyncData(afterRefreshEventData);
httpBackend.when("GET", "/sync").respond(200, function() {
return NEXT_SYNC_DATA;
});
await Promise.all([
httpBackend.flushAllExpected(),
utils.syncPromise(client, 1),
]);
// Make sure the message are visible
const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents();
const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId());
expect(resultantEventIdsInTimeline).toEqual([
initialSyncEventData[0].event_id,
initialSyncEventData[1].event_id,
initialSyncEventData[2].event_id,
afterRefreshEventData[0].event_id,
]);
});
});
});
+598 -5
View File
@@ -1,5 +1,21 @@
import { MatrixEvent } from "../../src/models/event";
import { EventTimeline } from "../../src/models/event-timeline";
/*
Copyright 2022 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 { EventTimeline, MatrixEvent, RoomEvent, RoomStateEvent, RoomMemberEvent } from "../../src";
import { UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
@@ -60,6 +76,112 @@ describe("MatrixClient syncing", function() {
done();
});
});
it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => {
const roomId = "!cycles:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// Second sync: a leave (reject of some kind)
httpBackend.when("POST", "/leave").respond(200, {});
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: {
leave: {
[roomId]: {
account_data: { events: [] },
ephemeral: { events: [] },
state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "leave",
},
prev_content: {
membership: "invite",
},
// XXX: And other fields required on an event
}],
},
timeline: {
limited: false,
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "leave",
},
prev_content: {
membership: "invite",
},
// XXX: And other fields required on an event
}],
},
},
},
},
});
// Third sync: another invite
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { // Room, string, string
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("invite");
expect(oldMembership).toBeFalsy();
// Second fire: a leave
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("leave");
expect(oldMembership).toBe("invite");
// Third/final fire: a second invite
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("invite");
expect(oldMembership).toBe("leave");
});
});
// For maximum safety, "leave" the room after we register the handler
client.leave(roomId);
});
// noinspection ES6MissingAwait
client.startClient();
await httpBackend.flushAllExpected();
expect(fires).toBe(3);
});
});
describe("resolving invites to profile info", function() {
@@ -177,7 +299,7 @@ describe("MatrixClient syncing", function() {
httpBackend.when("GET", "/sync").respond(200, syncData);
let latestFiredName = null;
client.on("RoomMember.name", function(event, m) {
client.on(RoomMemberEvent.Name, function(event, m) {
if (m.userId === userC && m.roomId === roomOne) {
latestFiredName = m.name;
}
@@ -461,6 +583,477 @@ describe("MatrixClient syncing", function() {
xit("should update the room topic", function() {
});
describe("onMarkerStateEvent", () => {
const normalMessageEvent = utils.mkMessage({
room: roomOne, user: otherUserId, msg: "hello",
});
it('new marker event *NOT* from the room creator in a subsequent syncs ' +
'should *NOT* mark the timeline as needing a refresh', async () => {
const roomCreateEvent = utils.mkEvent({
type: "m.room.create", room: roomOne, user: otherUserId,
content: {
creator: otherUserId,
room_version: '9',
},
});
const normalFirstSync = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
normalFirstSync.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
const nextSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
nextSyncData.rooms.join[roomOne] = {
timeline: {
events: [
// In subsequent syncs, a marker event in timeline
// range should normally trigger
// `timelineNeedsRefresh=true` but this marker isn't
// being sent by the room creator so it has no
// special meaning in existing room versions.
utils.mkEvent({
type: UNSTABLE_MSC2716_MARKER.name,
room: roomOne,
// The important part we're testing is here!
// `userC` is not the room creator.
user: userC,
skey: "",
content: {
"m.insertion_id": "$abc",
},
}),
],
prev_batch: "pagTok",
},
};
// Ensure the marker is being sent by someone who is not the room creator
// because this is the main thing we're testing in this spec.
const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0];
expect(markerEvent.sender).toBeDefined();
expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender);
httpBackend.when("GET", "/sync").respond(200, normalFirstSync);
httpBackend.when("GET", "/sync").respond(200, nextSyncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(2),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
[{
label: 'In existing room versions (when the room creator sends the MSC2716 events)',
roomVersion: '9',
}, {
label: 'In a MSC2716 supported room version',
roomVersion: 'org.matrix.msc2716v3',
}].forEach((testMeta) => {
describe(testMeta.label, () => {
const roomCreateEvent = utils.mkEvent({
type: "m.room.create", room: roomOne, user: otherUserId,
content: {
creator: otherUserId,
room_version: testMeta.roomVersion,
},
});
const markerEventFromRoomCreator = utils.mkEvent({
type: UNSTABLE_MSC2716_MARKER.name, room: roomOne, user: otherUserId,
skey: "",
content: {
"m.insertion_id": "$abc",
},
});
const normalFirstSync = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
normalFirstSync.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
it('no marker event in sync response '+
'should *NOT* mark the timeline as needing a refresh (check for a sane default)', async () => {
const syncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
syncData.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
it('marker event already sent within timeline range when you join ' +
'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => {
const syncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
syncData.rooms.join[roomOne] = {
timeline: {
events: [markerEventFromRoomCreator],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
it('marker event already sent before joining (in state) ' +
'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => {
const syncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
syncData.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [
roomCreateEvent,
markerEventFromRoomCreator,
],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
it('new marker event in a subsequent syncs timeline range ' +
'should mark the timeline as needing a refresh', async () => {
const nextSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
nextSyncData.rooms.join[roomOne] = {
timeline: {
events: [
// In subsequent syncs, a marker event in timeline
// range should trigger `timelineNeedsRefresh=true`
markerEventFromRoomCreator,
],
prev_batch: "pagTok",
},
};
const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id;
// Only do the first sync
httpBackend.when("GET", "/sync").respond(200, normalFirstSync);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// Get the room after the first sync so the room is created
const room = client.getRoom(roomOne);
let emitCount = 0;
room.on(RoomEvent.HistoryImportedWithinTimeline, function(markerEvent, room) {
expect(markerEvent.getId()).toEqual(markerEventId);
expect(room.roomId).toEqual(roomOne);
emitCount += 1;
});
// Now do a subsequent sync with the marker event
httpBackend.when("GET", "/sync").respond(200, nextSyncData);
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
expect(room.getTimelineNeedsRefresh()).toEqual(true);
// Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted
expect(emitCount).toEqual(1);
});
// Mimic a marker event being sent far back in the scroll back but since our last sync
it('new marker event in sync state should mark the timeline as needing a refresh', async () => {
const nextSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
nextSyncData.rooms.join[roomOne] = {
timeline: {
events: [
utils.mkMessage({
room: roomOne, user: otherUserId, msg: "hello again",
}),
],
prev_batch: "pagTok",
},
state: {
events: [
// In subsequent syncs, a marker event in state
// should trigger `timelineNeedsRefresh=true`
markerEventFromRoomCreator,
],
},
};
httpBackend.when("GET", "/sync").respond(200, normalFirstSync);
httpBackend.when("GET", "/sync").respond(200, nextSyncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(2),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(true);
});
});
});
});
// Make sure the state listeners work and events are re-emitted properly from
// the client regardless if we reset and refresh the timeline.
describe('state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired', () => {
const EVENTS = [
utils.mkMessage({
room: roomOne, user: userA, msg: "we",
}),
utils.mkMessage({
room: roomOne, user: userA, msg: "could",
}),
utils.mkMessage({
room: roomOne, user: userA, msg: "be",
}),
utils.mkMessage({
room: roomOne, user: userA, msg: "heroes",
}),
];
const SOME_STATE_EVENT = utils.mkEvent({
event: true,
type: 'org.matrix.test_state',
room: roomOne,
user: userA,
skey: "",
content: {
"foo": "bar",
},
});
const USER_MEMBERSHIP_EVENT = utils.mkMembership({
room: roomOne, mship: "join", user: userA,
});
// This appears to work even if we comment out
// `RoomEvent.CurrentStateUpdated` part which triggers everything to
// re-listen after the `room.currentState` reference changes. I'm
// not sure how it's getting re-emitted.
it("should be able to listen to state events even after " +
"the timeline is reset during `limited` sync response", async () => {
// Create a room from the sync
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// Get the room after the first sync so the room is created
const room = client.getRoom(roomOne);
expect(room).toBeTruthy();
let stateEventEmitCount = 0;
client.on(RoomStateEvent.Update, () => {
stateEventEmitCount += 1;
});
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can listen to the room state events before the reset
expect(stateEventEmitCount).toEqual(1);
// Make a `limited` sync which will cause a `room.resetLiveTimeline`
const limitedSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
limitedSyncData.rooms.join[roomOne] = {
timeline: {
events: [
utils.mkMessage({
room: roomOne, user: otherUserId, msg: "world",
}),
],
// The important part, make the sync `limited`
limited: true,
prev_batch: "newerTok",
},
};
httpBackend.when("GET", "/sync").respond(200, limitedSyncData);
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// This got incremented again from processing the sync response
expect(stateEventEmitCount).toEqual(2);
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can still listen to the room state events after the reset
expect(stateEventEmitCount).toEqual(3);
});
// Make sure it re-registers the state listeners after the
// `room.currentState` reference changes
it("should be able to listen to state events even after " +
"refreshing the timeline", async () => {
const testClientWithTimelineSupport = new TestClient(
selfUserId,
"DEVICE",
selfAccessToken,
undefined,
{ timelineSupport: true },
);
httpBackend = testClientWithTimelineSupport.httpBackend;
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
client = testClientWithTimelineSupport.client;
// Create a room from the sync
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// Get the room after the first sync so the room is created
const room = client.getRoom(roomOne);
expect(room).toBeTruthy();
let stateEventEmitCount = 0;
client.on(RoomStateEvent.Update, () => {
stateEventEmitCount += 1;
});
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can listen to the room state events before the reset
expect(stateEventEmitCount).toEqual(1);
const eventsInRoom = syncData.rooms.join[roomOne].timeline.events;
const contextUrl = `/rooms/${encodeURIComponent(roomOne)}/context/` +
`${encodeURIComponent(eventsInRoom[0].event_id)}`;
httpBackend.when("GET", contextUrl)
.respond(200, function() {
return {
start: "start_token",
events_before: [EVENTS[1], EVENTS[0]],
event: EVENTS[2],
events_after: [EVENTS[3]],
state: [
USER_MEMBERSHIP_EVENT,
],
end: "end_token",
};
});
// Refresh the timeline. This will cause the `room.currentState`
// reference to change
await Promise.all([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can still listen to the room state events after the reset
expect(stateEventEmitCount).toEqual(2);
});
});
});
describe("timeline", function() {
@@ -516,7 +1109,7 @@ describe("MatrixClient syncing", function() {
awaitSyncEvent(),
]).then(function() {
const room = client.getRoom(roomTwo);
expect(room).toBeDefined();
expect(room).toBeTruthy();
const tok = room.getLiveTimeline()
.getPaginationToken(EventTimeline.BACKWARDS);
expect(tok).toEqual("roomtwotok");
@@ -545,7 +1138,7 @@ describe("MatrixClient syncing", function() {
let resetCallCount = 0;
// the token should be set *before* timelineReset is emitted
client.on("Room.timelineReset", function(room) {
client.on(RoomEvent.TimelineReset, function(room) {
resetCallCount++;
const tl = room.getLiveTimeline();
+165
View File
@@ -0,0 +1,165 @@
/*
Copyright 2022 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 { 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";
const ROOM_ID = '!ROOM:ID';
const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc';
const ENCRYPTED_EVENT: Partial<IEvent> = {
type: 'm.room.encrypted',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
sender_key: 'SENDER_CURVE25519',
session_id: SESSION_ID,
ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N'
+ 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl'
+ 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs',
},
room_id: '!ROOM:ID',
event_id: '$event1',
origin_server_ts: 1507753886000,
};
const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
first_message_index: 0,
forwarded_count: 0,
is_verified: false,
session_data: {
ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw'
+ '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ'
+ 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9'
+ 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy'
+ 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF'
+ 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV'
+ '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv'
+ 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe'
+ 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf'
+ 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy'
+ 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg',
mac: '5lxYBHQU80M',
ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14',
},
};
const CURVE25519_BACKUP_INFO: IKeyBackupInfo = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/**
* start an Olm session with a given recipient
*/
function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise<Olm.Session> {
return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => {
const otkId = Object.keys(keys)[0];
const otk = keys[otkId];
const session = new global.Olm.Session();
session.create_outbound(
olmAccount, recipientTestClient.getDeviceKey(), otk.key,
);
return session;
});
}
describe("megolm key backups", function() {
if (!global.Olm) {
logger.warn('not running megolm tests: Olm not present');
return;
}
const Olm = global.Olm;
let testOlmAccount: Account;
let aliceTestClient: TestClient;
beforeAll(function() {
return Olm.init();
});
beforeEach(async function() {
aliceTestClient = new TestClient(
"@alice:localhost", "xzcvb", "akjgkrgjs",
);
testOlmAccount = new Olm.Account();
testOlmAccount.create();
await aliceTestClient.client.initCrypto();
aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO;
});
afterEach(function() {
return aliceTestClient.stop();
});
it("Alice checks key backups when receiving a message she can't decrypt", function() {
const syncResponse = {
next_batch: 1,
rooms: {
join: {},
},
};
syncResponse.rooms.join[ROOM_ID] = {
timeline: {
events: [ENCRYPTED_EVENT],
},
};
return aliceTestClient.start().then(() => {
return createOlmSession(testOlmAccount, aliceTestClient);
}).then(() => {
const privkey = decodeRecoveryKey(RECOVERY_KEY);
return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey);
}).then(() => {
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse);
aliceTestClient.expectKeyBackupQuery(
ROOM_ID,
SESSION_ID,
200,
CURVE25519_KEY_BACKUP_DATA,
);
return aliceTestClient.httpBackend.flushAllExpected();
}).then(function(): Promise<MatrixEvent> {
const room = aliceTestClient.client.getRoom(ROOM_ID);
const event = room.getLiveTimeline().getEvents()[0];
if (event.getContent()) {
return Promise.resolve(event);
}
return new Promise((resolve, reject) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
}).then((event) => {
expect(event.getContent()).toEqual('testytest');
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+732
View File
@@ -0,0 +1,732 @@
/*
Copyright 2022 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.
*/
// eslint-disable-next-line no-restricted-imports
import MockHttpBackend from "matrix-mock-request";
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 {
MatrixClient, MatrixEvent, NotificationCountType, JoinRule, MatrixError,
EventType, IPushRules, PushRuleKind, TweakName, ClientEvent,
} from "../../src";
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
import { SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client";
describe("SlidingSyncSdk", () => {
let client: MatrixClient = null;
let httpBackend: MockHttpBackend = null;
let sdk: SlidingSyncSdk = null;
let mockSlidingSync: SlidingSync = null;
const selfUserId = "@alice:localhost";
const selfAccessToken = "aseukfgwef";
const mockifySlidingSync = (s: SlidingSync): SlidingSync => {
s.getList = jest.fn();
s.getListData = jest.fn();
s.getRoomSubscriptions = jest.fn();
s.listLength = jest.fn();
s.modifyRoomSubscriptionInfo = jest.fn();
s.modifyRoomSubscriptions = jest.fn();
s.registerExtension = jest.fn();
s.setList = jest.fn();
s.setListRanges = jest.fn();
s.start = jest.fn();
s.stop = jest.fn();
s.resend = jest.fn();
return s;
};
// shorthand way to make events without filling in all the fields
let eventIdCounter = 0;
const mkOwnEvent = (evType: string, content: object): IRoomEvent => {
eventIdCounter++;
return {
type: evType,
content: content,
sender: selfUserId,
origin_server_ts: Date.now(),
event_id: "$" + eventIdCounter,
};
};
const mkOwnStateEvent = (evType: string, content: object, stateKey?: string): IStateEvent => {
eventIdCounter++;
return {
type: evType,
state_key: stateKey,
content: content,
sender: selfUserId,
origin_server_ts: Date.now(),
event_id: "$" + eventIdCounter,
};
};
const assertTimelineEvents = (got: MatrixEvent[], want: IRoomEvent[]): void => {
expect(got.length).toEqual(want.length);
got.forEach((m, i) => {
expect(m.getType()).toEqual(want[i].type);
expect(m.getSender()).toEqual(want[i].sender);
expect(m.getId()).toEqual(want[i].event_id);
expect(m.getContent()).toEqual(want[i].content);
expect(m.getTs()).toEqual(want[i].origin_server_ts);
if (want[i].unsigned) {
expect(m.getUnsigned()).toEqual(want[i].unsigned);
}
const maybeStateEvent = want[i] as IStateEvent;
if (maybeStateEvent.state_key) {
expect(m.getStateKey()).toEqual(maybeStateEvent.state_key);
}
});
};
// assign client/httpBackend globals
const setupClient = async (testOpts?: Partial<IStoredClientOpts&{withCrypto: boolean}>) => {
testOpts = testOpts || {};
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
mockSlidingSync = mockifySlidingSync(new SlidingSync("", [], {}, client, 0));
if (testOpts.withCrypto) {
httpBackend.when("GET", "/room_keys/version").respond(404, {});
await client.initCrypto();
testOpts.crypto = client.crypto;
}
httpBackend.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts);
};
// tear down client/httpBackend globals
const teardownClient = () => {
client.stopClient();
return httpBackend.stop();
};
// find an extension on a SlidingSyncSdk instance
const findExtension = (name: string): Extension => {
expect(mockSlidingSync.registerExtension).toHaveBeenCalled();
const mockFn = mockSlidingSync.registerExtension as jest.Mock;
// find the extension
for (let i = 0; i < mockFn.mock.calls.length; i++) {
const calledExtension = mockFn.mock.calls[i][0] as Extension;
if (calledExtension && calledExtension.name() === name) {
return calledExtension;
}
}
fail("cannot find extension " + name);
};
describe("sync/stop", () => {
beforeAll(async () => {
await setupClient();
});
afterAll(teardownClient);
it("can sync()", async () => {
const hasSynced = sdk.sync();
await httpBackend.flushAllExpected();
await hasSynced;
expect(mockSlidingSync.start).toBeCalled();
});
it("can stop()", async () => {
sdk.stop();
expect(mockSlidingSync.stop).toBeCalled();
});
});
describe("rooms", () => {
beforeAll(async () => {
await setupClient();
});
afterAll(teardownClient);
describe("initial", () => {
beforeAll(async () => {
const hasSynced = sdk.sync();
await httpBackend.flushAllExpected();
await hasSynced;
});
// inject some rooms with different fields set.
// All rooms are new so they all have initial: true
const roomA = "!a_state_and_timeline:localhost";
const roomB = "!b_timeline_only:localhost";
const roomC = "!c_with_highlight_count:localhost";
const roomD = "!d_with_notif_count:localhost";
const roomE = "!e_with_invite:localhost";
const roomF = "!f_calc_room_name:localhost";
const data: Record<string, MSC3575RoomData> = {
[roomA]: {
name: "A",
required_state: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnStateEvent(EventType.RoomName, { name: "A" }, ""),
],
timeline: [
mkOwnEvent(EventType.RoomMessage, { body: "hello A" }),
mkOwnEvent(EventType.RoomMessage, { body: "world A" }),
],
initial: true,
},
[roomB]: {
name: "B",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnEvent(EventType.RoomMessage, { body: "hello B" }),
mkOwnEvent(EventType.RoomMessage, { body: "world B" }),
],
initial: true,
},
[roomC]: {
name: "C",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnEvent(EventType.RoomMessage, { body: "hello C" }),
mkOwnEvent(EventType.RoomMessage, { body: "world C" }),
],
highlight_count: 5,
initial: true,
},
[roomD]: {
name: "D",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnEvent(EventType.RoomMessage, { body: "hello D" }),
mkOwnEvent(EventType.RoomMessage, { body: "world D" }),
],
notification_count: 5,
initial: true,
},
[roomE]: {
name: "E",
required_state: [],
timeline: [],
invite_state: [
{
type: EventType.RoomMember,
content: { membership: "invite" },
state_key: selfUserId,
sender: "@bob:localhost",
event_id: "$room_e_invite",
origin_server_ts: 123456,
},
{
type: "m.room.join_rules",
content: { join_rule: "invite" },
state_key: "",
sender: "@bob:localhost",
event_id: "$room_e_join_rule",
origin_server_ts: 123456,
},
],
initial: true,
},
[roomF]: {
name: "#foo:localhost",
required_state: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnStateEvent(EventType.RoomCanonicalAlias, { alias: "#foo:localhost" }, ""),
mkOwnStateEvent(EventType.RoomName, { name: "This should be ignored" }, ""),
],
timeline: [
mkOwnEvent(EventType.RoomMessage, { body: "hello A" }),
mkOwnEvent(EventType.RoomMessage, { body: "world A" }),
],
initial: true,
},
};
it("can be created with required_state and timeline", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
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", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
const gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
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", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined();
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(data[roomC].highlight_count);
});
it("can be created with a notification_count", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined();
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).toEqual(data[roomD].notification_count);
});
it("can be created with invite_state", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
const gotRoom = client.getRoom(roomE);
expect(gotRoom).toBeDefined();
expect(gotRoom.getMyMembership()).toEqual("invite");
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
});
it("uses the 'name' field to caluclate the room name", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
const gotRoom = client.getRoom(roomF);
expect(gotRoom).toBeDefined();
expect(
gotRoom.name,
).toEqual(data[roomF].name);
});
describe("updating", () => {
it("can update with a new timeline event", async () => {
const newEvent = mkOwnEvent(EventType.RoomMessage, { body: "new event A" });
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, {
timeline: [newEvent],
required_state: [],
name: data[roomA].name,
});
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
const newTimeline = data[roomA].timeline;
newTimeline.push(newEvent);
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
});
it("can update with a new required_state event", async () => {
let gotRoom = client.getRoom(roomB);
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.getJoinRule()).toEqual(JoinRule.Restricted);
});
it("can update with a new highlight_count", async () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, {
name: data[roomC].name,
required_state: [],
timeline: [],
highlight_count: 1,
});
const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined();
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(1);
});
it("can update with a new notification_count", async () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, {
name: data[roomD].name,
required_state: [],
timeline: [],
notification_count: 1,
});
const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined();
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).toEqual(1);
});
});
});
});
describe("lifecycle", () => {
beforeAll(async () => {
await setupClient();
const hasSynced = sdk.sync();
await httpBackend.flushAllExpected();
await hasSynced;
});
const FAILED_SYNC_ERROR_THRESHOLD = 3; // would be nice to export the const in the actual class...
it("emits SyncState.Reconnecting when < FAILED_SYNC_ERROR_THRESHOLD & SyncState.Error when over", async () => {
mockSlidingSync.emit(
SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete,
{ pos: "h", lists: [], rooms: {}, extensions: {} }, null,
);
expect(sdk.getSyncState()).toEqual(SyncState.Syncing);
mockSlidingSync.emit(
SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"),
);
expect(sdk.getSyncState()).toEqual(SyncState.Reconnecting);
for (let i = 0; i < FAILED_SYNC_ERROR_THRESHOLD; i++) {
mockSlidingSync.emit(
SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new Error("generic"),
);
}
expect(sdk.getSyncState()).toEqual(SyncState.Error);
});
it("emits SyncState.Syncing after a previous SyncState.Error", async () => {
mockSlidingSync.emit(
SlidingSyncEvent.Lifecycle,
SlidingSyncState.Complete,
{ pos: "i", lists: [], rooms: {}, extensions: {} },
null,
);
expect(sdk.getSyncState()).toEqual(SyncState.Syncing);
});
it("emits SyncState.Error immediately when receiving M_UNKNOWN_TOKEN and stops syncing", async () => {
expect(mockSlidingSync.stop).not.toBeCalled();
mockSlidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.RequestFinished, null, new MatrixError({
errcode: "M_UNKNOWN_TOKEN",
message: "Oh no your access token is no longer valid",
}));
expect(sdk.getSyncState()).toEqual(SyncState.Error);
expect(mockSlidingSync.stop).toBeCalled();
});
});
describe("opts", () => {
afterEach(teardownClient);
it("can resolveProfilesToInvites", async () => {
await setupClient({
resolveInvitesToProfiles: true,
});
const roomId = "!resolveProfilesToInvites:localhost";
const invitee = "@invitee:localhost";
const inviteeProfile = {
avatar_url: "mxc://foobar",
displayname: "The Invitee",
};
httpBackend.when("GET", "/profile").respond(200, inviteeProfile);
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomId, {
initial: true,
name: "Room with Invite",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "invite" }, invitee),
],
});
await httpBackend.flush("/profile", 1, 1000);
const room = client.getRoom(roomId);
expect(room).toBeDefined();
const inviteeMember = room.getMember(invitee);
expect(inviteeMember).toBeDefined();
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
});
});
describe("ExtensionE2EE", () => {
let ext: Extension;
beforeAll(async () => {
await setupClient({
withCrypto: true,
});
const hasSynced = sdk.sync();
await httpBackend.flushAllExpected();
await hasSynced;
ext = findExtension("e2ee");
});
afterAll(async () => {
// needed else we do some async operations in the background which can cause Jest to whine:
// "Cannot log after tests are done. Did you forget to wait for something async in your test?"
// Attempted to log "Saving device tracking data null"."
client.crypto.stop();
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("can update device lists", () => {
ext.onResponse({
device_lists: {
changed: ["@alice:localhost"],
left: ["@bob:localhost"],
},
});
// TODO: more assertions?
});
it("can update OTK counts", () => {
client.crypto.updateOneTimeKeyCount = jest.fn();
ext.onResponse({
device_one_time_keys_count: {
signed_curve25519: 42,
},
});
expect(client.crypto.updateOneTimeKeyCount).toHaveBeenCalledWith(42);
ext.onResponse({
device_one_time_keys_count: {
not_signed_curve25519: 42,
// missing field -> default to 0
},
});
expect(client.crypto.updateOneTimeKeyCount).toHaveBeenCalledWith(0);
});
it("can update fallback keys", () => {
ext.onResponse({
device_unused_fallback_key_types: ["signed_curve25519"],
});
expect(client.crypto.getNeedsNewFallback()).toEqual(false);
ext.onResponse({
device_unused_fallback_key_types: ["not_signed_curve25519"],
});
expect(client.crypto.getNeedsNewFallback()).toEqual(true);
});
});
describe("ExtensionAccountData", () => {
let ext: Extension;
beforeAll(async () => {
await setupClient();
const hasSynced = sdk.sync();
await httpBackend.flushAllExpected();
await hasSynced;
ext = findExtension("account_data");
});
it("gets enabled on the initial request only", () => {
expect(ext.onRequest(true)).toEqual({
enabled: true,
});
expect(ext.onRequest(false)).toEqual(undefined);
});
it("processes global account data", async () => {
const globalType = "global_test";
const globalContent = {
info: "here",
};
let globalData = client.getAccountData(globalType);
expect(globalData).toBeUndefined();
ext.onResponse({
global: [
{
type: globalType,
content: globalContent,
},
],
});
globalData = client.getAccountData(globalType);
expect(globalData).toBeDefined();
expect(globalData.getContent()).toEqual(globalContent);
});
it("processes rooms account data", async () => {
const roomId = "!room:id";
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomId, {
name: "Room with account data",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
mkOwnEvent(EventType.RoomMessage, { body: "hello" }),
],
initial: true,
});
const roomContent = {
foo: "bar",
};
const roomType = "test";
ext.onResponse({
rooms: {
[roomId]: [
{
type: roomType,
content: roomContent,
},
],
},
});
const room = client.getRoom(roomId);
expect(room).toBeDefined();
const event = room.getAccountData(roomType);
expect(event).toBeDefined();
expect(event.getContent()).toEqual(roomContent);
});
it("doesn't crash for unknown room account data", async () => {
const unknownRoomId = "!unknown:id";
const roomType = "tester";
ext.onResponse({
rooms: {
[unknownRoomId]: [
{
type: roomType,
content: {
foo: "Bar",
},
},
],
},
});
const room = client.getRoom(unknownRoomId);
expect(room).toBeNull();
expect(client.getAccountData(roomType)).toBeUndefined();
});
it("can update push rules via account data", async () => {
const roomId = "!foo:bar";
const pushRulesContent: IPushRules = {
global: {
[PushRuleKind.RoomSpecific]: [{
enabled: true,
default: true,
pattern: "monkey",
actions: [
{
set_tweak: TweakName.Sound,
value: "default",
},
],
rule_id: roomId,
}],
},
};
let pushRule = client.getRoomPushRule("global", roomId);
expect(pushRule).toBeUndefined();
ext.onResponse({
global: [
{
type: EventType.PushRules,
content: pushRulesContent,
},
],
});
pushRule = client.getRoomPushRule("global", roomId);
expect(pushRule).toEqual(pushRulesContent.global[PushRuleKind.RoomSpecific][0]);
});
});
describe("ExtensionToDevice", () => {
let ext: Extension;
beforeAll(async () => {
await setupClient();
const hasSynced = sdk.sync();
await httpBackend.flushAllExpected();
await hasSynced;
ext = findExtension("to_device");
});
it("gets enabled with a limit on the initial request only", () => {
const reqJson: any = ext.onRequest(true);
expect(reqJson.enabled).toEqual(true);
expect(reqJson.limit).toBeGreaterThan(0);
expect(reqJson.since).toBeUndefined();
});
it("updates the since value", async () => {
ext.onResponse({
next_batch: "12345",
events: [],
});
expect(ext.onRequest(false)).toEqual({
since: "12345",
});
});
it("can handle missing fields", async () => {
ext.onResponse({
next_batch: "23456",
// no events array
});
});
it("emits to-device events on the client", async () => {
const toDeviceType = "custom_test";
const toDeviceContent = {
foo: "bar",
};
let called = false;
client.once(ClientEvent.ToDeviceEvent, (ev) => {
expect(ev.getContent()).toEqual(toDeviceContent);
expect(ev.getType()).toEqual(toDeviceType);
called = true;
});
ext.onResponse({
next_batch: "34567",
events: [
{
type: toDeviceType,
content: toDeviceContent,
},
],
});
expect(called).toBe(true);
});
it("can cancel key verification requests", async () => {
const seen: Record<string, boolean> = {};
client.on(ClientEvent.ToDeviceEvent, (ev) => {
const evType = ev.getType();
expect(seen[evType]).toBeFalsy();
seen[evType] = true;
if (evType === "m.key.verification.start" || evType === "m.key.verification.request") {
expect(ev.isCancelled()).toEqual(true);
} else {
expect(ev.isCancelled()).toEqual(false);
}
});
ext.onResponse({
next_batch: "45678",
events: [
// someone tries to verify keys
{
type: "m.key.verification.start",
content: {
transaction_id: "a",
},
},
{
type: "m.key.verification.request",
content: {
transaction_id: "a",
},
},
// then gives up
{
type: "m.key.verification.cancel",
content: {
transaction_id: "a",
},
},
],
});
});
});
});
+758
View File
@@ -0,0 +1,758 @@
/*
Copyright 2022 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.
*/
// eslint-disable-next-line no-restricted-imports
import EventEmitter from "events";
import MockHttpBackend from "matrix-mock-request";
import { SlidingSync, SlidingSyncState, ExtensionState, SlidingSyncEvent } from "../../src/sliding-sync";
import { TestClient } from "../TestClient";
import { logger } from "../../src/logger";
import { MatrixClient } from "../../src";
import { sleep } from "../../src/utils";
/**
* Tests for sliding sync. These tests are broken down into sub-tests which are reliant upon one another.
* Each test suite (describe block) uses a single MatrixClient/HTTPBackend and a single SlidingSync class.
* Each test will call different functions on SlidingSync which may depend on state from previous tests.
*/
describe("SlidingSync", () => {
let client: MatrixClient = null;
let httpBackend: MockHttpBackend = null;
const selfUserId = "@alice:localhost";
const selfAccessToken = "aseukfgwef";
const proxyBaseUrl = "http://localhost:8008";
const syncUrl = proxyBaseUrl + "/_matrix/client/unstable/org.matrix.msc3575/sync";
// assign client/httpBackend globals
const setupClient = () => {
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
};
// tear down client/httpBackend globals
const teardownClient = () => {
httpBackend.verifyNoOutstandingExpectation();
client.stopClient();
return httpBackend.stop();
};
describe("start/stop", () => {
beforeAll(setupClient);
afterAll(teardownClient);
let slidingSync: SlidingSync;
it("should start the sync loop upon calling start()", async () => {
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1);
const fakeResp = {
pos: "a",
lists: [],
rooms: {},
extensions: {},
};
httpBackend.when("POST", syncUrl).respond(200, fakeResp);
const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => {
expect(state).toEqual(SlidingSyncState.RequestFinished);
expect(resp).toEqual(fakeResp);
expect(err).toBeFalsy();
return true;
});
slidingSync.start();
await httpBackend.flushAllExpected();
await p;
});
it("should stop the sync loop upon calling stop()", () => {
slidingSync.stop();
httpBackend.verifyNoOutstandingExpectation();
});
});
describe("room subscriptions", () => {
beforeAll(setupClient);
afterAll(teardownClient);
const roomId = "!foo:bar";
const anotherRoomID = "!another:room";
let roomSubInfo = {
timeline_limit: 1,
required_state: [
["m.room.name", ""],
],
};
const wantRoomData = {
name: "foo bar",
required_state: [],
timeline: [],
};
let slidingSync: SlidingSync;
it("should be able to subscribe to a room", async () => {
// add the subscription
slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client, 1);
slidingSync.modifyRoomSubscriptions(new Set([roomId]));
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("room sub", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomId]).toEqual(roomSubInfo);
}).respond(200, {
pos: "a",
lists: [],
extensions: {},
rooms: {
[roomId]: wantRoomData,
},
});
const p = listenUntil(slidingSync, "SlidingSync.RoomData", (gotRoomId, gotRoomData) => {
expect(gotRoomId).toEqual(roomId);
expect(gotRoomData).toEqual(wantRoomData);
return true;
});
slidingSync.start();
await httpBackend.flushAllExpected();
await p;
});
it("should be possible to adjust room subscription info whilst syncing", async () => {
// listen for updated request
const newSubInfo = {
timeline_limit: 100,
required_state: [
["m.room.member", "*"],
],
};
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("adjusted sub", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomId]).toEqual(newSubInfo);
}).respond(200, {
pos: "a",
lists: [],
extensions: {},
rooms: {
[roomId]: wantRoomData,
},
});
const p = listenUntil(slidingSync, "SlidingSync.RoomData", (gotRoomId, gotRoomData) => {
expect(gotRoomId).toEqual(roomId);
expect(gotRoomData).toEqual(wantRoomData);
return true;
});
slidingSync.modifyRoomSubscriptionInfo(newSubInfo);
await httpBackend.flushAllExpected();
await p;
// need to set what the new subscription info is for subsequent tests
roomSubInfo = newSubInfo;
});
it("should be possible to add room subscriptions whilst syncing", async () => {
// listen for updated request
const anotherRoomData = {
name: "foo bar 2",
room_id: anotherRoomID,
// we should not fall over if fields are missing.
// required_state: [],
// timeline: [],
};
const anotherRoomDataFixed = {
name: anotherRoomData.name,
room_id: anotherRoomID,
required_state: [],
timeline: [],
};
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("new subs", body);
expect(body.room_subscriptions).toBeTruthy();
// only the new room is sent, the other is sticky
expect(body.room_subscriptions[anotherRoomID]).toEqual(roomSubInfo);
expect(body.room_subscriptions[roomId]).toBeUndefined();
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {
[anotherRoomID]: anotherRoomData,
},
});
const p = listenUntil(slidingSync, "SlidingSync.RoomData", (gotRoomId, gotRoomData) => {
expect(gotRoomId).toEqual(anotherRoomID);
expect(gotRoomData).toEqual(anotherRoomDataFixed);
return true;
});
const subs = slidingSync.getRoomSubscriptions();
subs.add(anotherRoomID);
slidingSync.modifyRoomSubscriptions(subs);
await httpBackend.flushAllExpected();
await p;
});
it("should be able to unsubscribe from a room", async () => {
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("unsub request", body);
expect(body.room_subscriptions).toBeFalsy();
expect(body.unsubscribe_rooms).toEqual([roomId]);
}).respond(200, {
pos: "b",
lists: [],
});
const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
// remove the subscription for the first room
slidingSync.modifyRoomSubscriptions(new Set([anotherRoomID]));
await httpBackend.flushAllExpected();
await p;
slidingSync.stop();
});
});
describe("lists", () => {
beforeAll(setupClient);
afterAll(teardownClient);
const roomA = "!a:localhost";
const roomB = "!b:localhost";
const roomC = "!c:localhost";
const rooms = {
[roomA]: {
name: "A",
required_state: [],
timeline: [],
},
[roomB]: {
name: "B",
required_state: [],
timeline: [],
},
[roomC]: {
name: "C",
required_state: [],
timeline: [],
},
};
const newRanges = [[0, 2], [3, 5]];
let slidingSync: SlidingSync;
it("should be possible to subscribe to a list", async () => {
// request first 3 rooms
const listReq = {
ranges: [[0, 2]],
sort: ["by_name"],
timeline_limit: 1,
required_state: [
["m.room.topic", ""],
],
filters: {
is_dm: true,
},
};
slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client, 1);
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("list", body);
expect(body.lists).toBeTruthy();
expect(body.lists[0]).toEqual(listReq);
}).respond(200, {
pos: "a",
lists: [{
count: 500,
ops: [{
op: "SYNC",
range: [0, 2],
room_ids: Object.keys(rooms),
}],
}],
rooms: rooms,
});
const listenerData = {};
const dataListener = (roomId, roomData) => {
expect(listenerData[roomId]).toBeFalsy();
listenerData[roomId] = roomData;
};
slidingSync.on(SlidingSyncEvent.RoomData, dataListener);
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
slidingSync.start();
await httpBackend.flushAllExpected();
await responseProcessed;
expect(listenerData[roomA]).toEqual(rooms[roomA]);
expect(listenerData[roomB]).toEqual(rooms[roomB]);
expect(listenerData[roomC]).toEqual(rooms[roomC]);
expect(slidingSync.listLength()).toEqual(1);
slidingSync.off(SlidingSyncEvent.RoomData, dataListener);
});
it("should be possible to retrieve list data", () => {
expect(slidingSync.getList(0)).toBeDefined();
expect(slidingSync.getList(5)).toBeNull();
expect(slidingSync.getListData(5)).toBeNull();
const syncData = slidingSync.getListData(0);
expect(syncData.joinedCount).toEqual(500); // from previous test
expect(syncData.roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
});
it("should be possible to adjust list ranges", async () => {
// modify the list ranges
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("next ranges", body.lists[0].ranges);
expect(body.lists).toBeTruthy();
expect(body.lists[0]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
}).respond(200, {
pos: "b",
lists: [{
count: 500,
ops: [{
op: "SYNC",
range: [0, 2],
room_ids: Object.keys(rooms),
}],
}],
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.RequestFinished;
});
slidingSync.setListRanges(0, newRanges);
await httpBackend.flushAllExpected();
await responseProcessed;
});
it("should be possible to add an extra list", async () => {
// add extra list
const extraListReq = {
ranges: [[0, 100]],
sort: ["by_name"],
filters: {
"is_dm": true,
},
};
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("extra list", body);
expect(body.lists).toBeTruthy();
expect(body.lists[0]).toEqual({
// only the ranges should be sent as the rest are unchanged and sticky
ranges: newRanges,
});
expect(body.lists[1]).toEqual(extraListReq);
}).respond(200, {
pos: "c",
lists: [
{
count: 500,
},
{
count: 50,
ops: [{
op: "SYNC",
range: [0, 2],
room_ids: Object.keys(rooms),
}],
},
],
});
listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(1);
expect(joinedCount).toEqual(50);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
slidingSync.setList(1, extraListReq);
await httpBackend.flushAllExpected();
await responseProcessed;
});
it("should be possible to get list DELETE/INSERTs", async () => {
// move C (2) to A (0)
httpBackend.when("POST", syncUrl).respond(200, {
pos: "e",
lists: [{
count: 500,
ops: [{
op: "DELETE",
index: 2,
}, {
op: "INSERT",
index: 0,
room_id: roomC,
}],
},
{
count: 50,
}],
});
let listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomA,
2: roomB,
});
return true;
});
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
// move C (0) back to A (2)
httpBackend.when("POST", syncUrl).respond(200, {
pos: "f",
lists: [{
count: 500,
ops: [{
op: "DELETE",
index: 0,
}, {
op: "INSERT",
index: 2,
room_id: roomC,
}],
},
{
count: 50,
}],
});
listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should ignore invalid list indexes", async () => {
httpBackend.when("POST", syncUrl).respond(200, {
pos: "e",
lists: [{
count: 500,
ops: [{
op: "DELETE",
index: 2324324,
}],
},
{
count: 50,
}],
});
const listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomA,
1: roomB,
2: roomC,
});
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should be possible to update a list", async () => {
httpBackend.when("POST", syncUrl).respond(200, {
pos: "g",
lists: [{
count: 42,
ops: [
{
op: "INVALIDATE",
range: [0, 2],
},
{
op: "SYNC",
range: [0, 1],
room_ids: [roomB, roomC],
},
],
},
{
count: 50,
}],
});
// update the list with a new filter
slidingSync.setList(0, {
filters: {
is_encrypted: true,
},
ranges: [[0, 100]],
});
const listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(42);
expect(roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
slidingSync.stop();
});
});
describe("extensions", () => {
beforeAll(setupClient);
afterAll(teardownClient);
let slidingSync: SlidingSync;
const extReq = {
foo: "bar",
};
const extResp = {
baz: "quuz",
};
// Pre-extensions get called BEFORE processing the sync response
const preExtName = "foobar";
let onPreExtensionRequest;
let onPreExtensionResponse;
// Post-extensions get called AFTER processing the sync response
const postExtName = "foobar2";
let onPostExtensionRequest;
let onPostExtensionResponse;
const extPre = {
name: () => preExtName,
onRequest: (initial) => { return onPreExtensionRequest(initial); },
onResponse: (res) => { return onPreExtensionResponse(res); },
when: () => ExtensionState.PreProcess,
};
const extPost = {
name: () => postExtName,
onRequest: (initial) => { return onPostExtensionRequest(initial); },
onResponse: (res) => { return onPostExtensionResponse(res); },
when: () => ExtensionState.PostProcess,
};
it("should be able to register an extension", async () => {
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1);
slidingSync.registerExtension(extPre);
const callbackOrder = [];
let extensionOnResponseCalled = false;
onPreExtensionRequest = () => {
return extReq;
};
onPreExtensionResponse = (resp) => {
extensionOnResponseCalled = true;
callbackOrder.push("onPreExtensionResponse");
expect(resp).toEqual(extResp);
};
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("ext req", body);
expect(body.extensions).toBeTruthy();
expect(body.extensions[preExtName]).toEqual(extReq);
}).respond(200, {
pos: "a",
ops: [],
counts: [],
extensions: {
[preExtName]: extResp,
},
});
const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => {
if (state === SlidingSyncState.Complete) {
callbackOrder.push("Lifecycle");
return true;
}
});
slidingSync.start();
await httpBackend.flushAllExpected();
await p;
expect(extensionOnResponseCalled).toBe(true);
expect(callbackOrder).toEqual(["onPreExtensionResponse", "Lifecycle"]);
});
it("should be able to send nothing in an extension request/response", async () => {
onPreExtensionRequest = () => {
return undefined;
};
let responseCalled = false;
onPreExtensionResponse = (resp) => {
responseCalled = true;
};
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("ext req nothing", body);
expect(body.extensions).toBeTruthy();
expect(body.extensions[preExtName]).toBeUndefined();
}).respond(200, {
pos: "a",
ops: [],
counts: [],
extensions: {},
});
// we need to resend as sliding sync will already have a buffered request with the old
// extension values from the previous test.
slidingSync.resend();
const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await p;
expect(responseCalled).toBe(false);
});
it("is possible to register extensions after start() has been called", async () => {
slidingSync.registerExtension(extPost);
onPostExtensionRequest = () => {
return extReq;
};
let responseCalled = false;
const callbackOrder = [];
onPostExtensionResponse = (resp) => {
expect(resp).toEqual(extResp);
responseCalled = true;
callbackOrder.push("onPostExtensionResponse");
};
httpBackend.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("ext req after start", body);
expect(body.extensions).toBeTruthy();
expect(body.extensions[preExtName]).toBeUndefined(); // from the earlier test
expect(body.extensions[postExtName]).toEqual(extReq);
}).respond(200, {
pos: "c",
ops: [],
counts: [],
extensions: {
[postExtName]: extResp,
},
});
// we need to resend as sliding sync will already have a buffered request with the old
// extension values from the previous test.
slidingSync.resend();
const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state, resp, err) => {
if (state === SlidingSyncState.Complete) {
callbackOrder.push("Lifecycle");
return true;
}
});
await httpBackend.flushAllExpected();
await p;
expect(responseCalled).toBe(true);
expect(callbackOrder).toEqual(["Lifecycle", "onPostExtensionResponse"]);
slidingSync.stop();
});
it("is not possible to register the same extension name twice", async () => {
slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client, 1);
slidingSync.registerExtension(extPre);
expect(() => { slidingSync.registerExtension(extPre); }).toThrow();
});
});
});
async function timeout(delayMs: number, reason: string): Promise<never> {
await sleep(delayMs);
throw new Error(`timeout: ${delayMs}ms - ${reason}`);
}
/**
* Listen until a callback returns data.
* @param {EventEmitter} emitter The event emitter
* @param {string} eventName The event to listen for
* @param {function} callback The callback which will be invoked when events fire. Return something truthy from this to resolve the promise.
* @param {number} timeoutMs The number of milliseconds to wait for the callback to return data. Default: 500ms.
* @returns {Promise} A promise which will be resolved when the callback returns data. If the callback throws or the timeout is reached,
* the promise is rejected.
*/
function listenUntil<T>(
emitter: EventEmitter,
eventName: string,
callback: (...args: any[]) => T,
timeoutMs = 500,
): Promise<T> {
const trace = new Error().stack.split(`\n`)[2];
return Promise.race([new Promise<T>((resolve, reject) => {
const wrapper = (...args) => {
try {
const data = callback(...args);
if (data) {
emitter.off(eventName, wrapper);
resolve(data);
}
} catch (err) {
reject(err);
}
};
emitter.on(eventName, wrapper);
}), timeout(timeoutMs, "timed out waiting for event " + eventName + " " + trace)]);
}
+10 -4
View File
@@ -27,6 +27,7 @@ type InfoContentProps = {
isLive?: boolean;
assetType?: LocationAssetType;
description?: string;
timestamp?: number;
};
const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = {
timeout: 3600000,
@@ -42,21 +43,26 @@ export const makeBeaconInfoEvent = (
roomId: string,
contentProps: Partial<InfoContentProps> = {},
eventId?: string,
eventTypeSuffix?: string,
): MatrixEvent => {
const {
timeout, isLive, description, assetType,
timeout,
isLive,
description,
assetType,
timestamp,
} = {
...DEFAULT_INFO_CONTENT_PROPS,
...contentProps,
};
const event = new MatrixEvent({
type: `${M_BEACON_INFO.name}.${sender}.${eventTypeSuffix || Date.now()}`,
type: M_BEACON_INFO.name,
room_id: roomId,
state_key: sender,
content: makeBeaconInfoContent(timeout, isLive, description, assetType),
content: makeBeaconInfoContent(timeout, isLive, description, assetType, timestamp),
});
event.event.origin_server_ts = timestamp || Date.now();
// live beacons use the beacon_info event id
// set or default this
event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`);
+91 -10
View File
@@ -6,7 +6,7 @@ import '../olm-loader';
import { logger } from '../../src/logger';
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, MatrixClient } from "../../src";
import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
@@ -70,11 +70,10 @@ export function mock<T>(constr: { new(...args: any[]): T }, name: string): T {
interface IEventOpts {
type: EventType | string;
room: string;
room?: string;
sender?: string;
skey?: string;
content: IContent;
event?: boolean;
user?: string;
unsigned?: IUnsigned;
redacts?: string;
@@ -93,7 +92,9 @@ let testEventIndex = 1; // counter for events, easier for comparison of randomly
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object} a JSON object representing this event.
*/
export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent {
export function mkEvent(opts: IEventOpts & { event: true }, client?: MatrixClient): MatrixEvent;
export function mkEvent(opts: IEventOpts & { event?: false }, client?: MatrixClient): Partial<IEvent>;
export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixClient): Partial<IEvent> | MatrixEvent {
if (!opts.type || !opts.content) {
throw new Error("Missing .type or .content =>" + JSON.stringify(opts));
}
@@ -128,6 +129,21 @@ export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | Matri
return opts.event ? new MatrixEvent(event) : event;
}
type GeneratedMetadata = {
event_id: string;
txn_id: string;
origin_server_ts: number;
};
export function mkEventCustom<T>(base: T): T & GeneratedMetadata {
return {
event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(),
txn_id: "~" + Math.random(),
origin_server_ts: Date.now(),
...base,
};
}
interface IPresenceOpts {
user?: string;
sender?: string;
@@ -143,7 +159,9 @@ interface IPresenceOpts {
* @param {Object} opts Values for the presence.
* @return {Object|MatrixEvent} The event
*/
export function mkPresence(opts: IPresenceOpts): object | MatrixEvent {
export function mkPresence(opts: IPresenceOpts & { event: true }): MatrixEvent;
export function mkPresence(opts: IPresenceOpts & { event?: false }): Partial<IEvent>;
export function mkPresence(opts: IPresenceOpts & { event?: boolean }): Partial<IEvent> | MatrixEvent {
const event = {
event_id: "$" + Math.random() + "-" + Math.random(),
type: "m.presence",
@@ -159,7 +177,7 @@ export function mkPresence(opts: IPresenceOpts): object | MatrixEvent {
}
interface IMembershipOpts {
room: string;
room?: string;
mship: string;
sender?: string;
user?: string;
@@ -182,7 +200,9 @@ interface IMembershipOpts {
* @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object|MatrixEvent} The event
*/
export function mkMembership(opts: IMembershipOpts): object | MatrixEvent {
export function mkMembership(opts: IMembershipOpts & { event: true }): MatrixEvent;
export function mkMembership(opts: IMembershipOpts & { event?: false }): Partial<IEvent>;
export function mkMembership(opts: IMembershipOpts & { event?: boolean }): Partial<IEvent> | MatrixEvent {
const eventOpts: IEventOpts = {
...opts,
type: EventType.RoomMember,
@@ -203,8 +223,20 @@ export function mkMembership(opts: IMembershipOpts): object | MatrixEvent {
return mkEvent(eventOpts);
}
export function mkMembershipCustom<T>(
base: T & { membership: string, sender: string, content?: IContent },
): T & { type: EventType, sender: string, state_key: string, content: IContent } & GeneratedMetadata {
const content = base.content || {};
return mkEventCustom({
...base,
content: { ...content, membership: base.membership },
type: EventType.RoomMember,
state_key: base.sender,
});
}
interface IMessageOpts {
room: string;
room?: string;
user: string;
msg?: string;
event?: boolean;
@@ -220,12 +252,17 @@ interface IMessageOpts {
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object|MatrixEvent} The event
*/
export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent {
export function mkMessage(opts: IMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent;
export function mkMessage(opts: IMessageOpts & { event?: false }, client?: MatrixClient): Partial<IEvent>;
export function mkMessage(
opts: IMessageOpts & { event?: boolean },
client?: MatrixClient,
): Partial<IEvent> | MatrixEvent {
const eventOpts: IEventOpts = {
...opts,
type: EventType.RoomMessage,
content: {
msgtype: "m.text",
msgtype: MsgType.Text,
body: opts.msg,
},
};
@@ -236,6 +273,50 @@ export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | M
return mkEvent(eventOpts, client);
}
interface IReplyMessageOpts extends IMessageOpts {
replyToMessage: MatrixEvent;
}
/**
* Create a reply message.
*
* @param {Object} opts Values for the message
* @param {string} opts.room The room ID for the event.
* @param {string} opts.user The user ID for the event.
* @param {string} opts.msg Optional. The content.body for the event.
* @param {MatrixEvent} opts.replyToMessage The replied message
* @param {boolean} opts.event True to make a MatrixEvent.
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object|MatrixEvent} The event
*/
export function mkReplyMessage(opts: IReplyMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent;
export function mkReplyMessage(opts: IReplyMessageOpts & { event?: false }, client?: MatrixClient): Partial<IEvent>;
export function mkReplyMessage(
opts: IReplyMessageOpts & { event?: boolean },
client?: MatrixClient,
): Partial<IEvent> | MatrixEvent {
const eventOpts: IEventOpts = {
...opts,
type: EventType.RoomMessage,
content: {
"msgtype": MsgType.Text,
"body": opts.msg,
"m.relates_to": {
"rel_type": "m.in_reply_to",
"event_id": opts.replyToMessage.getId(),
"m.in_reply_to": {
"event_id": opts.replyToMessage.getId(),
},
},
},
};
if (!eventOpts.content.body) {
eventOpts.content.body = "Random->" + Math.random();
}
return mkEvent(eventOpts, client);
}
/**
* A mock implementation of webstorage
*
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2022 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.
*/
export const DUMMY_SDP = (
"v=0\r\n" +
"o=- 5022425983810148698 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" +
"a=msid-semantic: WMS h3wAi7s8QpiQMH14WG3BnDbmlOqo9I5ezGZA\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126\r\n" +
"c=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:hLDR\r\n" +
"a=ice-pwd:bMGD9aOldHWiI+6nAq/IIlRw\r\n" +
"a=ice-options:trickle\r\n" +
"a=fingerprint:sha-256 E4:94:84:F9:4A:98:8A:56:F5:5F:FD:AF:72:B9:32:89:49:5C:4B:9A:" +
"4A:15:8E:41:8A:F3:69:E4:39:52:DC:D6\r\n" +
"a=setup:active\r\n" +
"a=mid:0\r\n" +
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
"a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
"a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
"a=sendrecv\r\n" +
"a=msid:h3wAi7s8QpiQMH14WG3BnDbmlOqo9I5ezGZA 4357098f-3795-4131-bff4-9ba9c0348c49\r\n" +
"a=rtcp-mux\r\n" +
"a=rtpmap:111 opus/48000/2\r\n" +
"a=rtcp-fb:111 transport-cc\r\n" +
"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
"a=rtpmap:103 ISAC/16000\r\n" +
"a=rtpmap:104 ISAC/32000\r\n" +
"a=rtpmap:9 G722/8000\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=rtpmap:8 PCMA/8000\r\n" +
"a=rtpmap:106 CN/32000\r\n" +
"a=rtpmap:105 CN/16000\r\n" +
"a=rtpmap:13 CN/8000\r\n" +
"a=rtpmap:110 telephone-event/48000\r\n" +
"a=rtpmap:112 telephone-event/32000\r\n" +
"a=rtpmap:113 telephone-event/16000\r\n" +
"a=rtpmap:126 telephone-event/8000\r\n" +
"a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n"
);
export class MockRTCPeerConnection {
localDescription: RTCSessionDescription;
constructor() {
this.localDescription = {
sdp: DUMMY_SDP,
type: 'offer',
toJSON: function() { },
};
}
addEventListener() { }
createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; }
createOffer() {
return Promise.resolve({});
}
setRemoteDescription() {
return Promise.resolve();
}
setLocalDescription() {
return Promise.resolve();
}
close() { }
getStats() { return []; }
addTrack(track: MockMediaStreamTrack) { return new MockRTCRtpSender(track); }
}
export class MockRTCRtpSender {
constructor(public track: MockMediaStreamTrack) { }
replaceTrack(track: MockMediaStreamTrack) { this.track = track; }
}
export class MockMediaStreamTrack {
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) { }
stop() { }
}
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
// implementation
export class MockMediaStream {
constructor(
public id: string,
private tracks: MockMediaStreamTrack[] = [],
) {}
listeners: [string, (...args: any[]) => any][] = [];
dispatchEvent(eventType: string) {
this.listeners.forEach(([t, c]) => {
if (t !== eventType) return;
c();
});
}
getTracks() { return this.tracks; }
getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
addEventListener(eventType: string, callback: (...args: any[]) => any) {
this.listeners.push([eventType, callback]);
}
removeEventListener(eventType: string, callback: (...args: any[]) => any) {
this.listeners.filter(([t, c]) => {
return t !== eventType || c !== callback;
});
}
addTrack(track: MockMediaStreamTrack) {
this.tracks.push(track);
this.dispatchEvent("addtrack");
}
removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); }
}
export class MockMediaDeviceInfo {
constructor(
public kind: "audio" | "video",
) { }
}
export class MockMediaHandler {
getUserMediaStream(audio: boolean, video: boolean) {
const tracks = [];
if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio"));
if (video) tracks.push(new MockMediaStreamTrack("video_track", "video"));
return new MockMediaStream("mock_stream_from_media_handler", tracks);
}
stopUserMediaStream() { }
hasAudioDevice() { return true; }
}
@@ -1,6 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 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.
@@ -17,19 +17,20 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import * as sdk from "../../src";
import { request } from "../../src/matrix";
import { AutoDiscovery } from "../../src/autodiscovery";
describe("AutoDiscovery", function() {
let httpBackend = null;
beforeEach(function() {
httpBackend = new MockHttpBackend();
sdk.request(httpBackend.requestFn);
});
const getHttpBackend = (): MockHttpBackend => {
const httpBackend = new MockHttpBackend();
request(httpBackend.requestFn);
return httpBackend;
};
it("should throw an error when no domain is specified", function() {
getHttpBackend();
return Promise.all([
// @ts-ignore testing no args
AutoDiscovery.findClientConfig(/* no args */).then(() => {
throw new Error("Expected a failure, not success with no args");
}, () => {
@@ -42,13 +43,13 @@ describe("AutoDiscovery", function() {
return true;
}),
AutoDiscovery.findClientConfig(null).then(() => {
AutoDiscovery.findClientConfig(null as any).then(() => {
throw new Error("Expected a failure, not success with null");
}, () => {
return true;
}),
AutoDiscovery.findClientConfig(true).then(() => {
AutoDiscovery.findClientConfig(true as any).then(() => {
throw new Error("Expected a failure, not success with a non-string");
}, () => {
return true;
@@ -57,6 +58,7 @@ describe("AutoDiscovery", function() {
});
it("should return PROMPT when .well-known 404s", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(404, {});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -80,6 +82,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns a 500 error", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(500, {});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -103,6 +106,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns a 400 error", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(400, {});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -126,6 +130,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns an empty body", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "");
return Promise.all([
httpBackend.flushAllExpected(),
@@ -149,6 +154,7 @@ describe("AutoDiscovery", function() {
});
it("should return FAIL_PROMPT when .well-known returns not-JSON", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "abc");
return Promise.all([
httpBackend.flushAllExpected(),
@@ -173,6 +179,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_PROMPT when .well-known does not have a base_url for " +
"m.homeserver (empty string)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
base_url: "",
@@ -201,6 +208,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_PROMPT when .well-known does not have a base_url for " +
"m.homeserver (no property)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {},
});
@@ -227,6 +235,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (disallowed scheme)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
base_url: "mxc://example.org",
@@ -255,6 +264,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (verification failure: 404)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -284,6 +294,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (verification failure: 500)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -313,6 +324,7 @@ describe("AutoDiscovery", function() {
it("should return FAIL_ERROR when .well-known has an invalid base_url for " +
"m.homeserver (verification failure: 200 but wrong content)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
not_matrix_versions: ["r0.0.1"],
});
@@ -344,8 +356,9 @@ describe("AutoDiscovery", 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").check((req) => {
expect(req.opts.uri).toEqual("https://example.org/_matrix/client/versions");
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
@@ -376,8 +389,9 @@ describe("AutoDiscovery", function() {
});
it("should return SUCCESS with the right homeserver URL", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -411,8 +425,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (missing base_url)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -451,8 +466,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (empty base_url)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -491,8 +507,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (validation error: 404)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -532,8 +549,9 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS / FAIL_PROMPT when the identity server configuration " +
"is wrong (validation error: 500)", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
@@ -573,14 +591,15 @@ describe("AutoDiscovery", function() {
it("should return SUCCESS when the identity server configuration is " +
"verifiably accurate", function() {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://identity.example.org/_matrix/identity/api/v1");
}).respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -615,14 +634,15 @@ describe("AutoDiscovery", 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").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => {
expect(req.opts.uri)
expect(req.path)
.toEqual("https://identity.example.org/_matrix/identity/api/v1");
}).respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
+75 -7
View File
@@ -16,9 +16,14 @@ limitations under the License.
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location";
import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers";
import { M_TOPIC } from "../../src/@types/topic";
import {
makeBeaconContent,
makeBeaconInfoContent,
makeTopicContent,
parseTopicContent,
} from "../../src/content-helpers";
describe('Beacon content helpers', () => {
describe('makeBeaconInfoContent()', () => {
@@ -36,11 +41,9 @@ describe('Beacon content helpers', () => {
'nice beacon_info',
LocationAssetType.Pin,
)).toEqual({
[M_BEACON_INFO.name]: {
description: 'nice beacon_info',
timeout: 1234,
live: true,
},
description: 'nice beacon_info',
timeout: 1234,
live: true,
[M_TIMESTAMP.name]: mockDateNow,
[M_ASSET.name]: {
type: LocationAssetType.Pin,
@@ -125,3 +128,68 @@ describe('Beacon content helpers', () => {
});
});
});
describe('Topic content helpers', () => {
describe('makeTopicContent()', () => {
it('creates fully defined event content without html', () => {
expect(makeTopicContent("pizza")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}],
});
});
it('creates fully defined event content with html', () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}, {
body: "<b>pizza</b>",
mimetype: "text/html",
}],
});
});
});
describe('parseTopicContent()', () => {
it('parses event content with plain text topic without mimetype', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
}],
})).toEqual({
text: "pizza",
});
});
it('parses event content with plain text topic', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}],
})).toEqual({
text: "pizza",
});
});
it('parses event content with html topic', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "<b>pizza</b>",
mimetype: "text/html",
}],
})).toEqual({
text: "pizza",
html: "<b>pizza</b>",
});
});
});
});
@@ -19,41 +19,41 @@ describe("ContentRepo", function() {
});
it("should return a download URL if no width/height/resize are specified",
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid",
);
});
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid",
);
});
it("should return the empty string for null input", function() {
expect(getHttpUriForMxc(null)).toEqual("");
expect(getHttpUriForMxc(null as any, '')).toEqual("");
});
it("should return a thumbnail URL if a width/height/resize is specified",
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
function() {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
"?width=32&height=64&method=crop",
);
});
);
});
it("should put fragments from mxc:// URIs after any query parameters",
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual(
baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" +
"?width=32#automade",
);
});
);
});
it("should put fragments from mxc:// URIs at the end of the HTTP URI",
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade",
);
});
function() {
const mxcUri = "mxc://server.name/resourceid#automade";
expect(getHttpUriForMxc(baseUrl, mxcUri)).toEqual(
baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade",
);
});
});
});
-428
View File
@@ -1,428 +0,0 @@
import '../olm-loader';
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import { Crypto } from "../../src/crypto";
import { WebStorageSessionStore } from "../../src/store/session/webstorage";
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../MockStorageApi";
import { TestClient } from "../TestClient";
import { MatrixEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import * as olmlib from "../../src/crypto/olmlib";
import { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
const Olm = global.Olm;
describe("Crypto", function() {
if (!CRYPTO_ENABLED) {
return;
}
beforeAll(function() {
return Olm.init();
});
it("Crypto exposes the correct olm library version", function() {
expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
describe("encrypted events", function() {
it("provides encryption information", async function() {
const client = (new TestClient(
"@alice:example.com", "deviceid",
)).client;
await client.initCrypto();
// unencrypted event
const event = {
getId: () => "$event_id",
getSenderKey: () => null,
getWireContent: () => {return {};},
};
let encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeFalsy();
// unknown sender (e.g. deleted device), forwarded megolm key (untrusted)
event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
event.getWireContent = () => {return { algorithm: olmlib.MEGOLM_ALGORITHM };};
event.getForwardingCurve25519KeyChain = () => ["not empty"];
event.isKeySourceUntrusted = () => false;
event.getClaimedEd25519Key =
() => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeFalsy();
expect(encryptionInfo.sender).toBeFalsy();
// known sender, megolm key from backup
event.getForwardingCurve25519KeyChain = () => [];
event.isKeySourceUntrusted = () => true;
const device = new DeviceInfo("FLIBBLE");
device.keys["curve25519:FLIBBLE"] =
'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
device.keys["ed25519:FLIBBLE"] =
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
client.crypto.deviceList.getDeviceByIdentityKey = () => device;
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeFalsy();
expect(encryptionInfo.sender).toBeTruthy();
expect(encryptionInfo.mismatchedSender).toBeFalsy();
// known sender, trusted megolm key, but bad ed25519key
event.isKeySourceUntrusted = () => false;
device.keys["ed25519:FLIBBLE"] =
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeTruthy();
expect(encryptionInfo.sender).toBeTruthy();
expect(encryptionInfo.mismatchedSender).toBeTruthy();
client.stopClient();
});
});
describe('Session management', function() {
const otkResponse = {
one_time_keys: {
'@alice:home.server': {
aliceDevice: {
'signed_curve25519:FLIBBLE': {
key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI',
signatures: {
'@alice:home.server': {
'ed25519:aliceDevice': 'totally a valid signature',
},
},
},
},
},
},
};
let crypto;
let mockBaseApis;
let mockRoomList;
let fakeEmitter;
beforeEach(async function() {
const mockStorage = new MockStorageApi();
const sessionStore = new WebStorageSessionStore(mockStorage);
const cryptoStore = new MemoryCryptoStore(mockStorage);
cryptoStore.storeEndToEndDeviceData({
devices: {
'@bob:home.server': {
'BOBDEVICE': {
keys: {
'curve25519:BOBDEVICE': 'this is a key',
},
},
},
},
trackingStatus: {},
});
mockBaseApis = {
sendToDevice: jest.fn(),
getKeyBackupVersion: jest.fn(),
isGuest: jest.fn(),
};
mockRoomList = {};
fakeEmitter = new EventEmitter();
crypto = new Crypto(
mockBaseApis,
sessionStore,
"@alice:home.server",
"FLIBBLE",
sessionStore,
cryptoStore,
mockRoomList,
);
crypto.registerEventHandlers(fakeEmitter);
await crypto.init();
});
afterEach(async function() {
await crypto.stop();
});
it("restarts wedged Olm sessions", async function() {
const prom = new Promise((resolve) => {
mockBaseApis.claimOneTimeKeys = function() {
resolve();
return otkResponse;
};
});
fakeEmitter.emit('toDeviceEvent', {
getId: jest.fn().mockReturnValue("$wedged"),
getType: jest.fn().mockReturnValue('m.room.message'),
getContent: jest.fn().mockReturnValue({
msgtype: 'm.bad.encrypted',
}),
getWireContent: jest.fn().mockReturnValue({
algorithm: 'm.olm.v1.curve25519-aes-sha2',
sender_key: 'this is a key',
}),
getSender: jest.fn().mockReturnValue('@bob:home.server'),
});
await prom;
});
});
describe('Key requests', function() {
let aliceClient;
let bobClient;
beforeEach(async function() {
aliceClient = (new TestClient(
"@alice:example.com", "alicedevice",
)).client;
bobClient = (new TestClient(
"@bob:example.com", "bobdevice",
)).client;
await aliceClient.initCrypto();
await bobClient.initCrypto();
});
afterEach(async function() {
aliceClient.stopClient();
bobClient.stopClient();
});
it(
"does not cancel keyshare requests if some messages are not decrypted",
async function() {
function awaitEvent(emitter, event) {
return new Promise((resolve, reject) => {
emitter.once(event, (result) => {
resolve(result);
});
});
}
async function keyshareEventForEvent(event, index) {
const eventContent = event.getWireContent();
const key = await aliceClient.crypto.olmDevice
.getInboundGroupSessionKey(
roomId, eventContent.sender_key, eventContent.session_id,
index,
);
const ksEvent = new MatrixEvent({
type: "m.forwarded_room_key",
sender: "@alice:example.com",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: eventContent.sender_key,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
session_id: eventContent.session_id,
session_key: key.key,
chain_index: key.chain_index,
forwarding_curve25519_key_chain:
key.forwarding_curve_key_chain,
},
});
// make onRoomKeyEvent think this was an encrypted event
ksEvent.senderCurve25519Key = "akey";
return ksEvent;
}
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
event.clearEvent = undefined;
event.senderCurve25519Key = null;
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
}
}));
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
let eventPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
// keyshare the session key starting at the second message, so
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(events[1], 1);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
// the room key request should still be there, since we haven't
// decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody))
.toBeDefined();
// keyshare the session key starting at the first message, so
// that it can now be decrypted
eventPromise = awaitEvent(events[0], "Event.decrypted");
ksEvent = await keyshareEventForEvent(events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
await sleep(1);
// the room key request should be gone since we've now decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody))
.toBeFalsy();
},
);
it("creates a new keyshare request if we request a keyshare", async function() {
// make sure that cancelAndResend... creates a new keyshare request
// if there wasn't an already-existing one
const event = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
session_id: "sessionid",
sender_key: "senderkey",
},
});
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
const cryptoStore = aliceClient.cryptoStore;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: "!someroom",
session_id: "sessionid",
sender_key: "senderkey",
};
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody))
.toBeDefined();
});
it("uses a new txnid for re-requesting keys", async function() {
jest.useFakeTimers();
const event = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
session_id: "sessionid",
sender_key: "senderkey",
},
});
// replace Alice's sendToDevice function with a mock
aliceClient.sendToDevice = jest.fn().mockResolvedValue(undefined);
aliceClient.startClient();
// make a room key request, and record the transaction ID for the
// sendToDevice call
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
// key requests get queued until the sync has finished, but we don't
// let the client set up enough for that to happen, so gut-wrench a bit
// to force it to send now.
aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests();
jest.runAllTimers();
await Promise.resolve();
expect(aliceClient.sendToDevice).toBeCalledTimes(1);
const txnId = aliceClient.sendToDevice.mock.calls[0][2];
// give the room key request manager time to update the state
// of the request
await Promise.resolve();
// cancel and resend the room key request
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
jest.runAllTimers();
await Promise.resolve();
// cancelAndResend will call sendToDevice twice:
// the first call to sendToDevice will be the cancellation
// the second call to sendToDevice will be the key request
expect(aliceClient.sendToDevice).toBeCalledTimes(3);
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
});
describe('Secret storage', function() {
it("creates secret storage even if there is no keyInfo", async function() {
jest.spyOn(logger, 'log').mockImplementation(() => {});
jest.setTimeout(10000);
const client = (new TestClient("@a:example.com", "dev")).client;
await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null;
client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = () => null;
client.crypto.baseApis.setAccountData = () => null;
client.crypto.baseApis.uploadKeySignatures = () => null;
client.crypto.baseApis.http.authedRequest = () => null;
const createSecretStorageKey = async () => {
return {
keyInfo: undefined, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
};
};
await client.crypto.bootstrapSecretStorage({
createSecretStorageKey,
});
});
});
});
+591
View File
@@ -0,0 +1,591 @@
import '../olm-loader';
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import { MatrixClient } from "../../src/client";
import { Crypto } from "../../src/crypto";
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../MockStorageApi";
import { TestClient } from "../TestClient";
import { MatrixEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import * as olmlib from "../../src/crypto/olmlib";
import { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
import { MemoryStore } from "../../src";
const Olm = global.Olm;
function awaitEvent(emitter, event) {
return new Promise((resolve, reject) => {
emitter.once(event, (result) => {
resolve(result);
});
});
}
async function keyshareEventForEvent(client, event, index): Promise<MatrixEvent> {
const roomId = event.getRoomId();
const eventContent = event.getWireContent();
const key = await client.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
eventContent.sender_key,
eventContent.session_id,
index,
);
const ksEvent = new MatrixEvent({
type: "m.forwarded_room_key",
sender: client.getUserId(),
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: eventContent.sender_key,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
session_id: eventContent.session_id,
session_key: key.key,
chain_index: key.chain_index,
forwarding_curve25519_key_chain:
key.forwarding_curve_key_chain,
},
});
// make onRoomKeyEvent think this was an encrypted event
// @ts-ignore private property
ksEvent.senderCurve25519Key = "akey";
return ksEvent;
}
describe("Crypto", function() {
if (!CRYPTO_ENABLED) {
return;
}
beforeAll(function() {
return Olm.init();
});
afterEach(() => {
jest.useRealTimers();
});
it("Crypto exposes the correct olm library version", function() {
expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
describe("encrypted events", function() {
it("provides encryption information", async function() {
const client = (new TestClient(
"@alice:example.com", "deviceid",
)).client;
await client.initCrypto();
// unencrypted event
const event = {
getId: () => "$event_id",
getSenderKey: () => null,
getWireContent: () => {return {};},
} as unknown as MatrixEvent;
let encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeFalsy();
// unknown sender (e.g. deleted device), forwarded megolm key (untrusted)
event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
event.getWireContent = () => {return { algorithm: olmlib.MEGOLM_ALGORITHM };};
event.getForwardingCurve25519KeyChain = () => ["not empty"];
event.isKeySourceUntrusted = () => false;
event.getClaimedEd25519Key =
() => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeFalsy();
expect(encryptionInfo.sender).toBeFalsy();
// known sender, megolm key from backup
event.getForwardingCurve25519KeyChain = () => [];
event.isKeySourceUntrusted = () => true;
const device = new DeviceInfo("FLIBBLE");
device.keys["curve25519:FLIBBLE"] =
'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
device.keys["ed25519:FLIBBLE"] =
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
client.crypto.deviceList.getDeviceByIdentityKey = () => device;
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeFalsy();
expect(encryptionInfo.sender).toBeTruthy();
expect(encryptionInfo.mismatchedSender).toBeFalsy();
// known sender, trusted megolm key, but bad ed25519key
event.isKeySourceUntrusted = () => false;
device.keys["ed25519:FLIBBLE"] =
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
encryptionInfo = client.getEventEncryptionInfo(event);
expect(encryptionInfo.encrypted).toBeTruthy();
expect(encryptionInfo.authenticated).toBeTruthy();
expect(encryptionInfo.sender).toBeTruthy();
expect(encryptionInfo.mismatchedSender).toBeTruthy();
client.stopClient();
});
});
describe('Session management', function() {
const otkResponse = {
one_time_keys: {
'@alice:home.server': {
aliceDevice: {
'signed_curve25519:FLIBBLE': {
key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI',
signatures: {
'@alice:home.server': {
'ed25519:aliceDevice': 'totally a valid signature',
},
},
},
},
},
},
};
let crypto;
let mockBaseApis;
let mockRoomList;
let fakeEmitter;
beforeEach(async function() {
const mockStorage = new MockStorageApi();
const clientStore = new MemoryStore({ localStorage: mockStorage });
const cryptoStore = new MemoryCryptoStore();
cryptoStore.storeEndToEndDeviceData({
devices: {
'@bob:home.server': {
'BOBDEVICE': {
algorithms: [],
verified: 1,
known: false,
keys: {
'curve25519:BOBDEVICE': 'this is a key',
},
},
},
},
trackingStatus: {},
}, {});
mockBaseApis = {
sendToDevice: jest.fn(),
getKeyBackupVersion: jest.fn(),
isGuest: jest.fn(),
};
mockRoomList = {};
fakeEmitter = new EventEmitter();
crypto = new Crypto(
mockBaseApis,
"@alice:home.server",
"FLIBBLE",
clientStore,
cryptoStore,
mockRoomList,
[],
);
crypto.registerEventHandlers(fakeEmitter);
await crypto.init();
});
afterEach(async function() {
await crypto.stop();
});
it("restarts wedged Olm sessions", async function() {
const prom = new Promise<void>((resolve) => {
mockBaseApis.claimOneTimeKeys = function() {
resolve();
return otkResponse;
};
});
fakeEmitter.emit('toDeviceEvent', {
getId: jest.fn().mockReturnValue("$wedged"),
getType: jest.fn().mockReturnValue('m.room.message'),
getContent: jest.fn().mockReturnValue({
msgtype: 'm.bad.encrypted',
}),
getWireContent: jest.fn().mockReturnValue({
algorithm: 'm.olm.v1.curve25519-aes-sha2',
sender_key: 'this is a key',
}),
getSender: jest.fn().mockReturnValue('@bob:home.server'),
});
await prom;
});
});
describe('Key requests', function() {
let aliceClient: MatrixClient;
let bobClient: MatrixClient;
beforeEach(async function() {
aliceClient = (new TestClient(
"@alice:example.com", "alicedevice",
)).client;
bobClient = (new TestClient(
"@bob:example.com", "bobdevice",
)).client;
await aliceClient.initCrypto();
await bobClient.initCrypto();
});
afterEach(async function() {
aliceClient.stopClient();
bobClient.stopClient();
});
it("does not cancel keyshare requests if some messages are not decrypted", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private properties
event.clearEvent = undefined;
// @ts-ignore private properties
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
}
}));
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const decryptEventsPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
// keyshare the session key starting at the second message, so
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventsPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.crypto.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
// the room key request should still be there, since we haven't
// decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
// keyshare the session key starting at the first message, so
// that it can now be decrypted
const decryptEventPromise = awaitEvent(events[0], "Event.decrypted");
ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await decryptEventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
await sleep(1);
// the room key request should be gone since we've now decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy();
});
it("should error if a forwarded room key lacks a content.sender_key", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const event = new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
});
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
// remove keys from the event
// @ts-ignore private property
event.clearEvent = undefined;
// @ts-ignore private property
event.senderCurve25519Key = null;
// @ts-ignore private property
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
}
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const ksEvent = await keyshareEventForEvent(aliceClient, event, 1);
ksEvent.getContent().sender_key = undefined; // test
bobClient.crypto.olmDevice.addInboundGroupSession = jest.fn();
await bobDecryptor.onRoomKeyEvent(ksEvent);
expect(bobClient.crypto.olmDevice.addInboundGroupSession).not.toHaveBeenCalled();
});
it("creates a new keyshare request if we request a keyshare", async function() {
// make sure that cancelAndResend... creates a new keyshare request
// if there wasn't an already-existing one
const event = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
session_id: "sessionid",
sender_key: "senderkey",
},
});
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
const cryptoStore = aliceClient.crypto.cryptoStore;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: "!someroom",
session_id: "sessionid",
sender_key: "senderkey",
};
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody))
.toBeDefined();
});
it("uses a new txnid for re-requesting keys", async function() {
jest.useFakeTimers();
const event = new MatrixEvent({
sender: "@bob:example.com",
room_id: "!someroom",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
session_id: "sessionid",
sender_key: "senderkey",
},
});
// replace Alice's sendToDevice function with a mock
const aliceSendToDevice = jest.fn().mockResolvedValue(undefined);
aliceClient.sendToDevice = aliceSendToDevice;
aliceClient.startClient();
// make a room key request, and record the transaction ID for the
// sendToDevice call
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
// key requests get queued until the sync has finished, but we don't
// let the client set up enough for that to happen, so gut-wrench a bit
// to force it to send now.
// @ts-ignore
aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests();
jest.runAllTimers();
await Promise.resolve();
expect(aliceSendToDevice).toBeCalledTimes(1);
const txnId = aliceSendToDevice.mock.calls[0][2];
// give the room key request manager time to update the state
// of the request
await Promise.resolve();
// cancel and resend the room key request
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
jest.runAllTimers();
await Promise.resolve();
// cancelAndResend will call sendToDevice twice:
// the first call to sendToDevice will be the cancellation
// the second call to sendToDevice will be the key request
expect(aliceSendToDevice).toBeCalledTimes(3);
expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
});
describe('Secret storage', function() {
it("creates secret storage even if there is no keyInfo", async function() {
jest.spyOn(logger, 'log').mockImplementation(() => {});
jest.setTimeout(10000);
const client = (new TestClient("@a:example.com", "dev")).client;
await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null;
client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto.baseApis.setAccountData = () => null;
client.crypto.baseApis.uploadKeySignatures = () => null;
client.crypto.baseApis.http.authedRequest = () => null;
const createSecretStorageKey = async () => {
return {
keyInfo: undefined, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
};
};
await client.crypto.bootstrapSecretStorage({
createSecretStorageKey,
});
client.stopClient();
});
});
describe("encryptAndSendToDevices", () => {
let client: TestClient;
let ensureOlmSessionsForDevices: jest.SpiedFunction<typeof olmlib.ensureOlmSessionsForDevices>;
let encryptMessageForDevice: jest.SpiedFunction<typeof olmlib.encryptMessageForDevice>;
const payload = { hello: "world" };
let encryptedPayload: object;
beforeEach(async () => {
ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices");
ensureOlmSessionsForDevices.mockResolvedValue({});
encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice");
encryptMessageForDevice.mockImplementation(async (...[result,,,,,, payload]) => {
result.plaintext = JSON.stringify(payload);
});
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
encryptedPayload = {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: client.client.crypto.olmDevice.deviceCurve25519Key,
ciphertext: { plaintext: JSON.stringify(payload) },
};
});
afterEach(async () => {
ensureOlmSessionsForDevices.mockRestore();
encryptMessageForDevice.mockRestore();
await client.stop();
});
it("encrypts and sends to devices", async () => {
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted", {
messages: {
"@bob:example.org": {
bobweb: encryptedPayload,
bobmobile: encryptedPayload,
},
"@carol:example.org": {
caroldesktop: encryptedPayload,
},
},
})
.respond(200, {});
await Promise.all([
client.client.encryptAndSendToDevices(
[
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobmobile") },
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
],
payload,
),
client.httpBackend.flushAllExpected(),
]);
});
it("sends nothing to devices that couldn't be encrypted to", async () => {
encryptMessageForDevice.mockImplementation(async (...[result,,,, userId, device, payload]) => {
// Refuse to encrypt to Carol's desktop device
if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return;
result.plaintext = JSON.stringify(payload);
});
client.httpBackend
.when("PUT", "/sendToDevice/m.room.encrypted", {
// Carol is nowhere to be seen
messages: { "@bob:example.org": { bobweb: encryptedPayload } },
})
.respond(200, {});
await Promise.all([
client.client.encryptAndSendToDevices(
[
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") },
{ userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") },
],
payload,
),
client.httpBackend.flushAllExpected(),
]);
});
it("no-ops if no devices can be encrypted to", async () => {
// Refuse to encrypt to anybody
encryptMessageForDevice.mockResolvedValue(undefined);
// Get the room keys version request out of the way
client.httpBackend.when("GET", "/room_keys/version").respond(404, {});
await client.httpBackend.flush("/room_keys/version", 1);
await client.client.encryptAndSendToDevices(
[{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }],
payload,
);
client.httpBackend.verifyNoOutstandingRequests();
});
});
});
@@ -66,23 +66,23 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
});
it.each(types)("should throw if the callback returns falsey",
async ({ type, shouldCache }) => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: () => false,
async ({ type, shouldCache }) => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: async () => false as unknown as Uint8Array,
});
await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey");
});
await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey");
});
it("should throw if the expected key doesn't come back", async () => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: () => masterKeyPub,
getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array,
});
await expect(info.getCrossSigningKey("master", "")).rejects.toThrow();
});
it("should return a key from its callback", async () => {
const info = new CrossSigningInfo(userId, {
getCrossSigningKey: () => testKey,
getCrossSigningKey: async () => testKey,
});
const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
@@ -99,7 +99,7 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
it.each(types)("should request a key from the cache callback (if set)" +
" and does not call app if one is found" +
" %o",
async ({ type, shouldCache }) => {
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) {
return Promise.reject(new Error("Regular callback called"));
@@ -122,58 +122,58 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
});
it.each(types)("should store a key with the cache callback (if set)",
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ storeCrossSigningKeyCache },
);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
if (shouldCache) {
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
}
});
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ storeCrossSigningKeyCache },
);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
if (shouldCache) {
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
}
});
it.each(types)("does not store a bad key to the cache",
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(badKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ storeCrossSigningKeyCache },
);
await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow();
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0);
});
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(badKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ storeCrossSigningKeyCache },
);
await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow();
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0);
});
it.each(types)("does not store a value to the cache if it came from the cache",
async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) {
return Promise.reject(new Error("Regular callback called"));
} else {
return Promise.resolve(testKey);
}
const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) {
return Promise.reject(new Error("Regular callback called"));
} else {
return Promise.resolve(testKey);
}
});
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(
new Error("Tried to store a value from cache"),
);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
});
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(
new Error("Tried to store a value from cache"),
);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
});
it.each(types)("requests a key from the cache callback (if set) and then calls app" +
" if one is not found", async ({ type, shouldCache }) => {
@@ -220,12 +220,14 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
*/
describe.each([
["IndexedDBCryptoStore",
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore",
() => new IndexedDBCryptoStore(undefined, "tests")],
() => new IndexedDBCryptoStore(undefined, "tests")],
["MemoryCryptoStore", () => {
const store = new IndexedDBCryptoStore(undefined, "tests");
// @ts-ignore set private properties
store._backend = new MemoryCryptoStore();
// @ts-ignore
store._backendPromise = Promise.resolve(store._backend);
return store;
}],
@@ -1,7 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 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.
@@ -20,8 +20,10 @@ import { logger } from "../../../src/logger";
import * as utils from "../../../src/utils";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import { DeviceList } from "../../../src/crypto/DeviceList";
import { IDownloadKeyResult, MatrixClient } from "../../../src";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
const signedDeviceList = {
const signedDeviceList: IDownloadKeyResult = {
"failures": {},
"device_keys": {
"@test1:sw1v.org": {
@@ -45,13 +47,15 @@ const signedDeviceList = {
"m.megolm.v1.aes-sha2",
],
"device_id": "HGKAWHRVJQ",
"unsigned": {},
"unsigned": {
"device_display_name": "",
},
},
},
},
};
const signedDeviceList2 = {
const signedDeviceList2: IDownloadKeyResult = {
"failures": {},
"device_keys": {
"@test2:sw1v.org": {
@@ -75,7 +79,9 @@ const signedDeviceList2 = {
"m.megolm.v1.aes-sha2",
],
"device_id": "QJVRHWAKGH",
"unsigned": {},
"unsigned": {
"device_display_name": "",
},
},
},
},
@@ -104,10 +110,10 @@ describe('DeviceList', function() {
downloadKeysForUsers: downloadSpy,
getUserId: () => '@test1:sw1v.org',
deviceId: 'HGKAWHRVJQ',
};
} as unknown as MatrixClient;
const mockOlm = {
verifySignature: function(key, message, signature) {},
};
} as unknown as OlmDevice;
const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize);
deviceLists.push(dl);
return dl;
@@ -118,7 +124,7 @@ describe('DeviceList', function() {
dl.startTrackingDeviceList('@test1:sw1v.org');
const queryDefer1 = utils.defer();
const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer1.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
@@ -128,6 +134,7 @@ describe('DeviceList', function() {
return prom1.then(() => {
const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
dl.stop();
});
});
@@ -137,7 +144,7 @@ describe('DeviceList', function() {
dl.startTrackingDeviceList('@test1:sw1v.org');
const queryDefer1 = utils.defer();
const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer1.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
@@ -154,6 +161,7 @@ describe('DeviceList', function() {
dl.saveIfDirty().then(() => {
// the first request completes
queryDefer1.resolve({
failures: {},
device_keys: {
'@test1:sw1v.org': {},
},
@@ -165,11 +173,12 @@ describe('DeviceList', function() {
logger.log("Creating new devicelist to simulate app reload");
downloadSpy.mockReset();
const dl2 = createTestDeviceList();
const queryDefer3 = utils.defer();
const queryDefer3 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer3.promise);
const prom3 = dl2.refreshOutdatedDeviceLists();
expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {});
dl2.stop();
queryDefer3.resolve(utils.deepCopy(signedDeviceList));
@@ -178,6 +187,7 @@ describe('DeviceList', function() {
}).then(() => {
const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
dl.stop();
});
});
@@ -187,9 +197,9 @@ describe('DeviceList', function() {
dl.startTrackingDeviceList('@test1:sw1v.org');
dl.startTrackingDeviceList('@test2:sw1v.org');
const queryDefer1 = utils.defer();
const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValueOnce(queryDefer1.promise);
const queryDefer2 = utils.defer();
const queryDefer2 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValueOnce(queryDefer2.promise);
const prom1 = dl.refreshOutdatedDeviceLists();
@@ -204,6 +214,7 @@ describe('DeviceList', function() {
expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']);
const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org');
expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']);
dl.stop();
});
});
});
@@ -1,15 +1,36 @@
/*
Copyright 2022 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, MockedObject } from 'jest-mock';
import '../../../olm-loader';
import * as algorithms from "../../../../src/crypto/algorithms";
import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../../../MockStorageApi";
import * as testUtils from "../../../test-utils/test-utils";
import { OlmDevice } from "../../../../src/crypto/OlmDevice";
import { Crypto } from "../../../../src/crypto";
import { Crypto, IncomingRoomKeyRequest } from "../../../../src/crypto";
import { logger } from "../../../../src/logger";
import { MatrixEvent } from "../../../../src/models/event";
import { TestClient } from "../../../TestClient";
import { Room } from "../../../../src/models/room";
import * as olmlib from "../../../../src/crypto/olmlib";
import { TypedEventEmitter } from '../../../../src/models/typed-event-emitter';
import { ClientEvent, MatrixClient, RoomMember } from '../../../../src';
import { DeviceInfo, IDevice } from '../../../../src/crypto/deviceinfo';
import { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning';
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
@@ -28,17 +49,20 @@ describe("MegolmDecryption", function() {
return Olm.init();
});
let megolmDecryption;
let mockOlmLib;
let mockCrypto;
let mockBaseApis;
let megolmDecryption: algorithms.DecryptionAlgorithm;
let mockOlmLib: MockedObject<typeof olmlib>;
let mockCrypto: MockedObject<Crypto>;
let mockBaseApis: MockedObject<MatrixClient>;
beforeEach(async function() {
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockBaseApis = {};
mockCrypto = testUtils.mock(Crypto, 'Crypto') as MockedObject<Crypto>;
mockBaseApis = {
claimOneTimeKeys: jest.fn(),
sendToDevice: jest.fn(),
queueToDevice: jest.fn(),
} as unknown as MockedObject<MatrixClient>;
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
const olmDevice = new OlmDevice(cryptoStore);
@@ -51,11 +75,15 @@ describe("MegolmDecryption", function() {
});
// we stub out the olm encryption bits
mockOlmLib = {};
mockOlmLib.ensureOlmSessionsForDevices = jest.fn();
mockOlmLib.encryptMessageForDevice =
jest.fn().mockResolvedValue(undefined);
mockOlmLib = {
encryptMessageForDevice: jest.fn().mockResolvedValue(undefined),
ensureOlmSessionsForDevices: jest.fn(),
} as unknown as MockedObject<typeof olmlib>;
// @ts-ignore illegal assignment that makes these tests work :/
megolmDecryption.olmlib = mockOlmLib;
jest.clearAllMocks();
});
describe('receives some keys:', function() {
@@ -87,7 +115,7 @@ describe("MegolmDecryption", function() {
decryptEvent: function() {
return Promise.resolve(decryptedData);
},
};
} as unknown as Crypto;
await event.attemptDecryption(mockCrypto).then(() => {
megolmDecryption.onRoomKeyEvent(event);
@@ -115,10 +143,13 @@ describe("MegolmDecryption", function() {
});
it('can respond to a key request event', function() {
const keyRequest = {
const keyRequest: IncomingRoomKeyRequest = {
requestId: '123',
share: jest.fn(),
userId: '@alice:foo',
deviceId: 'alidevice',
requestBody: {
algorithm: '',
room_id: ROOM_ID,
sender_key: "SENDER_CURVE25519",
session_id: groupSession.session_id(),
@@ -131,23 +162,25 @@ describe("MegolmDecryption", function() {
expect(hasKeys).toBe(true);
// set up some pre-conditions for the share call
const deviceInfo = {};
const deviceInfo = {} as DeviceInfo;
mockCrypto.getStoredDevice.mockReturnValue(deviceInfo);
mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({
'@alice:foo': { 'alidevice': {
sessionId: 'alisession',
device: new DeviceInfo('alidevice'),
} },
});
const awaitEncryptForDevice = new Promise((res, rej) => {
const awaitEncryptForDevice = new Promise<void>((res, rej) => {
mockOlmLib.encryptMessageForDevice.mockImplementation(() => {
res();
return Promise.resolve();
});
});
mockBaseApis.sendToDevice = jest.fn();
mockBaseApis.sendToDevice.mockReset();
mockBaseApis.queueToDevice.mockReset();
// do the share
megolmDecryption.shareKeysWithDevice(keyRequest);
@@ -257,23 +290,26 @@ describe("MegolmDecryption", function() {
});
describe("session reuse and key reshares", () => {
const rotationPeriodMs = 999 * 24 * 60 * 60 * 1000; // 999 days, so we don't have to deal with it
let megolmEncryption;
let aliceDeviceInfo;
let mockRoom;
let olmDevice;
beforeEach(async () => {
// @ts-ignore assigning to readonly prop
mockCrypto.backupManager = {
backupGroupSession: () => {},
};
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
olmDevice = new OlmDevice(cryptoStore);
olmDevice.verifySignature = jest.fn();
await olmDevice.init();
mockBaseApis.claimOneTimeKeys = jest.fn().mockReturnValue(Promise.resolve({
mockBaseApis.claimOneTimeKeys.mockResolvedValue({
failures: {},
one_time_keys: {
'@alice:home.server': {
aliceDevice: {
@@ -288,8 +324,9 @@ describe("MegolmDecryption", function() {
},
},
},
}));
mockBaseApis.sendToDevice = jest.fn().mockResolvedValue(undefined);
});
mockBaseApis.sendToDevice.mockResolvedValue(undefined);
mockBaseApis.queueToDevice.mockResolvedValue(undefined);
aliceDeviceInfo = {
deviceId: 'aliceDevice',
@@ -309,18 +346,30 @@ describe("MegolmDecryption", function() {
mockCrypto.checkDeviceTrust.mockReturnValue({
isVerified: () => false,
});
} as DeviceTrustLevel);
megolmEncryption = new MegolmEncryption({
userId: '@user:id',
deviceId: '12345',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: mockBaseApis,
roomId: ROOM_ID,
config: {
rotation_period_ms: 9999999999999,
algorithm: 'm.megolm.v1.aes-sha2',
rotation_period_ms: rotationPeriodMs,
},
});
// Splice the real method onto the mock object as megolm uses this method
// on the crypto class in order to encrypt / start sessions
// @ts-ignore Mock
mockCrypto.encryptAndSendToDevices = Crypto.prototype.encryptAndSendToDevices;
// @ts-ignore Mock
mockCrypto.olmDevice = olmDevice;
// @ts-ignore Mock
mockCrypto.baseApis = mockBaseApis;
mockRoom = {
getEncryptionTargetMembers: jest.fn().mockReturnValue(
[{ userId: "@alice:home.server" }],
@@ -329,6 +378,31 @@ describe("MegolmDecryption", function() {
};
});
it("should use larger otkTimeout when preparing to encrypt room", async () => {
megolmEncryption.prepareToEncrypt(mockRoom);
await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text",
});
expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled();
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith(
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 10000,
);
});
it("should generate a new session if this one needs rotation", async () => {
const session = await megolmEncryption.prepareNewSession(false);
session.creationTime -= rotationPeriodMs + 10000; // a smidge over the rotation time
// Inject expired session which needs rotation
megolmEncryption.setupPromise = Promise.resolve(session);
const prepareNewSessionSpy = jest.spyOn(megolmEncryption, "prepareNewSession");
await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text",
});
expect(prepareNewSessionSpy).toHaveBeenCalledTimes(1);
});
it("re-uses sessions for sequential messages", async function() {
const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text",
@@ -342,7 +416,7 @@ describe("MegolmDecryption", function() {
expect(mockCrypto.downloadKeys).toHaveBeenCalledWith(
['@alice:home.server'], false,
);
expect(mockBaseApis.sendToDevice).toHaveBeenCalled();
expect(mockBaseApis.queueToDevice).toHaveBeenCalled();
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith(
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000,
);
@@ -385,7 +459,7 @@ describe("MegolmDecryption", function() {
'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWI',
);
mockBaseApis.sendToDevice.mockClear();
mockBaseApis.queueToDevice.mockClear();
await megolmEncryption.reshareKeyWithDevice(
olmDevice.deviceCurve25519Key,
ct1.session_id,
@@ -393,7 +467,7 @@ describe("MegolmDecryption", function() {
aliceDeviceInfo,
);
expect(mockBaseApis.sendToDevice).not.toHaveBeenCalled();
expect(mockBaseApis.queueToDevice).not.toHaveBeenCalled();
});
});
});
@@ -422,33 +496,33 @@ describe("MegolmDecryption", function() {
};
const roomId = "!someroom";
const room = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobMember = new RoomMember(roomId, "@bob:example.com");
room.getEncryptionTargetMembers = async function() {
return [{ userId: "@bob:example.com" }];
return [bobMember];
};
room.setBlacklistUnverifiedDevices(true);
aliceClient.store.storeRoom(room);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
const BOB_DEVICES = {
const BOB_DEVICES: Record<string, IDevice> = {
bobdevice1: {
user_id: "@bob:example.com",
device_id: "bobdevice1",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:Dynabook": bobDevice1.deviceEd25519Key,
"curve25519:Dynabook": bobDevice1.deviceCurve25519Key,
},
verified: 0,
known: false,
},
bobdevice2: {
user_id: "@bob:example.com",
device_id: "bobdevice2",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
keys: {
"ed25519:Dynabook": bobDevice2.deviceEd25519Key,
"curve25519:Dynabook": bobDevice2.deviceCurve25519Key,
},
verified: -1,
known: false,
},
};
@@ -459,32 +533,7 @@ describe("MegolmDecryption", function() {
return this.getDevicesFromStore(userIds);
};
let run = false;
aliceClient.sendToDevice = async (msgtype, contentMap) => {
run = true;
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"].bobdevice1.session_id;
delete contentMap["@bob:example.com"].bobdevice2.session_id;
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice1: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.unverified',
reason:
'The sender has disabled encrypting to unverified devices.',
sender_key: aliceDevice.deviceCurve25519Key,
},
bobdevice2: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.blacklisted',
reason: 'The sender has blocked you.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
};
aliceClient.sendToDevice = jest.fn().mockResolvedValue({});
const event = new MatrixEvent({
type: "m.room.message",
@@ -498,7 +547,30 @@ describe("MegolmDecryption", function() {
});
await aliceClient.crypto.encryptEvent(event, room);
expect(run).toBe(true);
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
delete contentMap["@bob:example.com"].bobdevice1.session_id;
delete contentMap["@bob:example.com"].bobdevice2.session_id;
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice1: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.unverified',
reason:
'The sender has disabled encrypting to unverified devices.',
sender_key: aliceDevice.deviceCurve25519Key,
},
bobdevice2: {
algorithm: "m.megolm.v1.aes-sha2",
room_id: roomId,
code: 'm.blacklisted',
reason: 'The sender has blocked you.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
aliceClient.stopClient();
bobClient1.stopClient();
@@ -530,18 +602,16 @@ describe("MegolmDecryption", function() {
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
aliceRoom.getEncryptionTargetMembers = async () => {
return [
{
userId: "@alice:example.com",
membership: "join",
},
{
userId: "@bob:example.com",
membership: "join",
},
];
};
aliceRoom.getEncryptionTargetMembers = jest.fn().mockResolvedValue([
{
userId: "@alice:example.com",
membership: "join",
},
{
userId: "@bob:example.com",
membership: "join",
},
]);
const BOB_DEVICES = {
bobdevice: {
user_id: "@bob:example.com",
@@ -563,30 +633,14 @@ describe("MegolmDecryption", function() {
return this.getDevicesFromStore(userIds);
};
aliceClient.claimOneTimeKeys = async () => {
aliceClient.claimOneTimeKeys = jest.fn().mockResolvedValue({
// Bob has no one-time keys
return {
one_time_keys: {},
};
};
const sendPromise = new Promise((resolve, reject) => {
aliceClient.sendToDevice = async (msgtype, contentMap) => {
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice: {
algorithm: "m.megolm.v1.aes-sha2",
code: 'm.no_olm',
reason: 'Unable to establish a secure channel.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
resolve();
};
one_time_keys: {},
failures: {},
});
aliceClient.sendToDevice = jest.fn().mockResolvedValue({});
const event = new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
@@ -595,7 +649,23 @@ describe("MegolmDecryption", function() {
content: {},
});
await aliceClient.crypto.encryptEvent(event, aliceRoom);
await sendPromise;
expect(aliceClient.sendToDevice).toHaveBeenCalled();
const [msgtype, contentMap] = mocked(aliceClient.sendToDevice).mock.calls[0];
expect(msgtype).toMatch(/^(org.matrix|m).room_key.withheld$/);
expect(contentMap).toStrictEqual({
'@bob:example.com': {
bobdevice: {
algorithm: "m.megolm.v1.aes-sha2",
code: 'm.no_olm',
reason: 'Unable to establish a secure channel.',
sender_key: aliceDevice.deviceCurve25519Key,
},
},
});
aliceClient.stopClient();
bobClient.stopClient();
});
it("throws an error describing why it doesn't have a key", async function() {
@@ -611,10 +681,13 @@ describe("MegolmDecryption", function() {
]);
const bobDevice = bobClient.crypto.olmDevice;
const aliceEventEmitter = new TypedEventEmitter<ClientEvent.ToDeviceEvent, any>();
aliceClient.crypto.registerEventHandlers(aliceEventEmitter);
const roomId = "!someroom";
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
type: "org.matrix.room_key.withheld",
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
algorithm: "m.megolm.v1.aes-sha2",
@@ -640,7 +713,7 @@ describe("MegolmDecryption", function() {
},
}))).rejects.toThrow("The sender has blocked you.");
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -666,6 +739,8 @@ describe("MegolmDecryption", function() {
session_id: "session_id2",
},
}))).rejects.toThrow("The sender has blocked you.");
aliceClient.stopClient();
bobClient.stopClient();
});
it("throws an error describing the lack of an olm session", async function() {
@@ -679,15 +754,19 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
aliceClient.crypto.downloadKeys = async () => {};
const aliceEventEmitter = new TypedEventEmitter<ClientEvent.ToDeviceEvent, any>();
aliceClient.crypto.registerEventHandlers(aliceEventEmitter);
aliceClient.crypto.downloadKeys = jest.fn();
const bobDevice = bobClient.crypto.olmDevice;
const roomId = "!someroom";
const now = Date.now();
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
type: "org.matrix.room_key.withheld",
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
algorithm: "m.megolm.v1.aes-sha2",
@@ -718,7 +797,7 @@ describe("MegolmDecryption", function() {
origin_server_ts: now,
}))).rejects.toThrow("The sender was unable to establish a secure channel.");
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room_key.withheld",
sender: "@bob:example.com",
content: {
@@ -749,6 +828,8 @@ describe("MegolmDecryption", function() {
},
origin_server_ts: now,
}))).rejects.toThrow("The sender was unable to establish a secure channel.");
aliceClient.stopClient();
bobClient.stopClient();
});
it("throws an error to indicate a wedged olm session", async function() {
@@ -762,15 +843,18 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
const aliceEventEmitter = new TypedEventEmitter<ClientEvent.ToDeviceEvent, any>();
aliceClient.crypto.registerEventHandlers(aliceEventEmitter);
const bobDevice = bobClient.crypto.olmDevice;
aliceClient.crypto.downloadKeys = async () => {};
aliceClient.crypto.downloadKeys = jest.fn();
const roomId = "!someroom";
const now = Date.now();
// pretend we got an event that we can't decrypt
aliceClient.crypto.onToDeviceEvent(new MatrixEvent({
aliceEventEmitter.emit(ClientEvent.ToDeviceEvent, new MatrixEvent({
type: "m.room.encrypted",
sender: "@bob:example.com",
content: {
@@ -799,5 +883,7 @@ describe("MegolmDecryption", function() {
},
origin_server_ts: now,
}))).rejects.toThrow("The secure channel with the sender was corrupted.");
aliceClient.stopClient();
bobClient.stopClient();
});
});
@@ -1,6 +1,6 @@
/*
Copyright 2018,2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 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.
@@ -15,17 +15,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MockedObject } from 'jest-mock';
import '../../../olm-loader';
import { MemoryCryptoStore } from "../../../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../../../MockStorageApi";
import { logger } from "../../../../src/logger";
import { OlmDevice } from "../../../../src/crypto/OlmDevice";
import * as olmlib from "../../../../src/crypto/olmlib";
import { DeviceInfo } from "../../../../src/crypto/deviceinfo";
import { MatrixClient } from '../../../../src';
function makeOlmDevice() {
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
const cryptoStore = new MemoryCryptoStore();
const olmDevice = new OlmDevice(cryptoStore);
return olmDevice;
}
@@ -51,8 +52,8 @@ describe("OlmDevice", function() {
return global.Olm.init();
});
let aliceOlmDevice;
let bobOlmDevice;
let aliceOlmDevice: OlmDevice;
let bobOlmDevice: OlmDevice;
beforeEach(async function() {
aliceOlmDevice = makeOlmDevice();
@@ -69,7 +70,7 @@ describe("OlmDevice", function() {
bobOlmDevice.deviceCurve25519Key,
sid,
"The olm or proteus is an aquatic salamander in the family Proteidae",
);
) as any; // OlmDevice.encryptMessage has incorrect return type
const result = await bobOlmDevice.createInboundSession(
aliceOlmDevice.deviceCurve25519Key,
@@ -96,7 +97,7 @@ describe("OlmDevice", function() {
bobOlmDevice.deviceCurve25519Key,
sessionId,
MESSAGE,
);
) as any; // OlmDevice.encryptMessage has incorrect return type
const bobRecreatedOlmDevice = makeOlmDevice();
bobRecreatedOlmDevice.init({ fromExportedDevice: exported });
@@ -120,7 +121,7 @@ describe("OlmDevice", function() {
bobOlmDevice.deviceCurve25519Key,
sessionId,
MESSAGE_2,
);
) as any; // OlmDevice.encryptMessage has incorrect return type
const bobRecreatedAgainOlmDevice = makeOlmDevice();
bobRecreatedAgainOlmDevice.init({ fromExportedDevice: exportedAgain });
@@ -148,7 +149,7 @@ describe("OlmDevice", function() {
setTimeout(reject, 500);
});
},
};
} as unknown as MockedObject<MatrixClient>;
const devicesByUser = {
"@bob:example.com": [
DeviceInfo.fromStorage({
@@ -205,7 +206,7 @@ describe("OlmDevice", function() {
setTimeout(reject, 500);
});
},
};
} as unknown as MockedObject<MatrixClient>;
const deviceBobA = DeviceInfo.fromStorage({
keys: {
@@ -15,20 +15,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MockedObject } from "jest-mock";
import '../../olm-loader';
import { logger } from "../../../src/logger";
import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixClient } from "../../../src/client";
import { MatrixEvent } from "../../../src/models/event";
import * as algorithms from "../../../src/crypto/algorithms";
import { WebStorageSessionStore } from "../../../src/store/session/webstorage";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../../MockStorageApi";
import * as testUtils from "../../test-utils/test-utils";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
import { Crypto } from "../../../src/crypto";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BackupManager } from "../../../src/crypto/backup";
import { StubStore } from "../../../src/store/stub";
import { IAbortablePromise, MatrixScheduler } from '../../../src';
const Olm = global.Olm;
@@ -93,8 +95,8 @@ const AES256_KEY_BACKUP_DATA = {
};
const CURVE25519_BACKUP_INFO = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: '1',
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
@@ -102,7 +104,7 @@ const CURVE25519_BACKUP_INFO = {
const AES256_BACKUP_INFO = {
algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2",
version: 1,
version: '1',
auth_data: {
// FIXME: add iv and mac
},
@@ -118,30 +120,22 @@ function saveCrossSigningKeys(k) {
Object.assign(keys, k);
}
function makeTestClient(sessionStore, cryptoStore) {
function makeTestClient(cryptoStore) {
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {});
store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null));
store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null));
store.setSyncData = jest.fn().mockReturnValue(Promise.resolve(null));
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject<MatrixScheduler>;
const store = new StubStore();
return new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
request: jest.fn(), // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys },
});
@@ -160,8 +154,6 @@ describe("MegolmBackup", function() {
let olmDevice;
let mockOlmLib;
let mockCrypto;
let mockStorage;
let sessionStore;
let cryptoStore;
let megolmDecryption;
beforeEach(async function() {
@@ -173,9 +165,7 @@ describe("MegolmBackup", function() {
);
mockCrypto.backupInfo = CURVE25519_BACKUP_INFO;
mockStorage = new MockStorageApi();
sessionStore = new WebStorageSessionStore(mockStorage);
cryptoStore = new MemoryCryptoStore(mockStorage);
cryptoStore = new MemoryCryptoStore();
olmDevice = new OlmDevice(cryptoStore);
@@ -188,7 +178,6 @@ describe("MegolmBackup", function() {
describe("backup", function() {
let mockBaseApis;
let realSetTimeout;
beforeEach(function() {
mockBaseApis = {};
@@ -206,14 +195,14 @@ describe("MegolmBackup", function() {
// clobber the setTimeout function to run 100x faster.
// ideally we would use lolex, but we have no oportunity
// to tick the clock between the first try and the retry.
realSetTimeout = global.setTimeout;
global.setTimeout = function(f, n) {
const realSetTimeout = global.setTimeout;
jest.spyOn(global, 'setTimeout').mockImplementation(function(f, n) {
return realSetTimeout(f, n/100);
};
});
});
afterEach(function() {
global.setTimeout = realSetTimeout;
jest.spyOn(global, 'setTimeout').mockRestore();
});
it('automatically calls the key back up', function() {
@@ -261,7 +250,7 @@ describe("MegolmBackup", function() {
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore);
const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
@@ -293,16 +282,16 @@ describe("MegolmBackup", function() {
txn);
});
})
.then(() => {
client.enableKeyBackup({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
.then(async () => {
await client.enableKeyBackup({
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: '1',
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
@@ -311,17 +300,17 @@ describe("MegolmBackup", function() {
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(queryParams.version).toBe('1');
expect(data.rooms[ROOM_ID].sessions).toBeDefined();
expect(data.rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
resolve();
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
};
client.crypto.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
@@ -329,6 +318,7 @@ describe("MegolmBackup", function() {
);
}).then(() => {
expect(numCalls).toBe(1);
client.stopClient();
});
});
});
@@ -339,7 +329,7 @@ describe("MegolmBackup", function() {
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore);
const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
@@ -374,17 +364,17 @@ describe("MegolmBackup", function() {
txn);
});
})
.then(() => {
client.enableKeyBackup({
.then(async () => {
await client.enableKeyBackup({
algorithm: "org.matrix.msc3270.v1.aes-hmac-sha2",
version: 1,
version: '1',
auth_data: {
iv: "PsCAtR7gMc4xBd9YS3A9Ow",
mac: "ZSDsTFEZK7QzlauCLMleUcX96GQZZM7UNtk4sripSqQ",
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
@@ -393,17 +383,17 @@ describe("MegolmBackup", function() {
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(queryParams.version).toBe('1');
expect(data.rooms[ROOM_ID].sessions).toBeDefined();
expect(data.rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
resolve();
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
};
client.crypto.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
@@ -411,6 +401,7 @@ describe("MegolmBackup", function() {
);
}).then(() => {
expect(numCalls).toBe(1);
client.stopClient();
});
});
});
@@ -421,7 +412,7 @@ describe("MegolmBackup", function() {
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore);
const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
@@ -434,19 +425,12 @@ describe("MegolmBackup", function() {
megolmDecryption.olmlib = mockOlmLib;
await client.initCrypto();
let privateKeys;
client.uploadDeviceSigningKeys = async function(e) {return;};
client.uploadKeySignatures = async function(e) {return;};
client.on("crossSigning.saveCrossSigningKeys", function(e) {
privateKeys = e;
});
client.on("crossSigning.getKey", function(e) {
e.done(privateKeys[e.type]);
});
client.uploadDeviceSigningKeys = async function(e) {return {};};
client.uploadKeySignatures = async function(e) {return { failures: {} };};
await resetCrossSigningKeys(client);
let numCalls = 0;
await Promise.all([
new Promise((resolve, reject) => {
new Promise<void>((resolve, reject) => {
let backupInfo;
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
@@ -463,33 +447,34 @@ describe("MegolmBackup", function() {
);
} catch (e) {
reject(e);
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
}
backupInfo = data;
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
} else if (numCalls === 2) {
expect(method).toBe("GET");
expect(path).toBe("/room_keys/version");
resolve();
return Promise.resolve(backupInfo);
return Promise.resolve(backupInfo) as IAbortablePromise<any>;
} else {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many times"));
return Promise.resolve({});
return Promise.resolve({}) as IAbortablePromise<any>;
}
};
}),
client.createKeyBackupVersion({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
}),
]);
expect(numCalls).toBe(2);
client.stopClient();
});
it('retries when a backup fails', function() {
it('retries when a backup fails', async function() {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
@@ -498,26 +483,17 @@ describe("MegolmBackup", function() {
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {});
store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null));
store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null));
store.setSyncData = jest.fn().mockReturnValue(Promise.resolve(null));
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}) as MockedObject<MatrixScheduler>;
const store = new StubStore();
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
request: jest.fn(), // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
});
@@ -531,70 +507,68 @@ describe("MegolmBackup", function() {
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto()
.then(() => {
return cryptoStore.doTxn(
"readwrite",
[cryptoStore.STORE_SESSION],
(txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice.pickleKey),
},
txn);
});
})
.then(() => {
client.enableKeyBackup({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
await client.initCrypto();
await cryptoStore.doTxn(
"readwrite",
[cryptoStore.STORE_SESSION],
(txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice.pickleKey),
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
if (numCalls >= 3) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(data.rooms[ROOM_ID].sessions).toBeDefined();
expect(data.rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
if (numCalls > 1) {
resolve();
return Promise.resolve({});
} else {
return Promise.reject(
new Error("this is an expected failure"),
);
}
};
client.crypto.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);
}).then(() => {
expect(numCalls).toBe(2);
});
txn);
});
await client.enableKeyBackup({
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
version: '1',
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
await new Promise<void>((resolve, reject) => {
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
if (numCalls >= 3) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({}) as IAbortablePromise<any>;
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe('1');
expect(data.rooms[ROOM_ID].sessions).toBeDefined();
expect(data.rooms[ROOM_ID].sessions).toHaveProperty(
groupSession.session_id(),
);
if (numCalls > 1) {
resolve();
return Promise.resolve({}) as IAbortablePromise<any>;
} else {
return Promise.reject(
new Error("this is an expected failure"),
) as IAbortablePromise<any>;
}
};
return client.crypto.backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
);
});
expect(numCalls).toBe(2);
client.stopClient();
});
});
@@ -602,7 +576,7 @@ describe("MegolmBackup", function() {
let client;
beforeEach(function() {
client = makeTestClient(sessionStore, cryptoStore);
client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
@@ -17,12 +17,16 @@ limitations under the License.
import '../../olm-loader';
import anotherjson from 'another-json';
import { PkSigning } from '@matrix-org/olm';
import * as olmlib from "../../../src/crypto/olmlib";
import { TestClient } from '../../TestClient';
import { resetCrossSigningKeys } from "./crypto-utils";
import { MatrixError } from '../../../src/http-api';
import { logger } from '../../../src/logger';
import { ICrossSigningKey, ICreateClientOpts, ISignedKey } from '../../../src/client';
import { CryptoEvent } from '../../../src/crypto';
import { IDevice } from '../../../src/crypto/deviceinfo';
import { TestClient } from '../../TestClient';
import { resetCrossSigningKeys } from "./crypto-utils";
const PUSH_RULES_RESPONSE = {
method: "GET",
@@ -47,9 +51,11 @@ function setHttpResponses(httpBackend, responses) {
});
}
async function makeTestClient(userInfo, options, keys) {
if (!keys) keys = {};
async function makeTestClient(
userInfo: { userId: string, deviceId: string},
options: Partial<ICreateClientOpts> = {},
keys = {},
) {
function getCrossSigningKey(type) {
return keys[type];
}
@@ -58,7 +64,6 @@ async function makeTestClient(userInfo, options, keys) {
Object.assign(keys, k);
}
if (!options) options = {};
options.cryptoCallbacks = Object.assign(
{}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {},
);
@@ -86,20 +91,21 @@ describe("Cross Signing", function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => {
alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => {
await olmlib.verifySignature(
alice.crypto.olmDevice, keys.master_key, "@alice:example.com",
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
);
});
alice.uploadKeySignatures = async () => {};
alice.setAccountData = async () => {};
alice.getAccountDataFromServer = async () => {};
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T>() => ({} as T);
// set Alice's cross-signing key
await alice.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async func => await func({}),
authUploadDeviceSigningKeys: async func => { await func({}); },
});
expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled();
alice.stopClient();
});
it("should abort bootstrap if device signing auth fails", async function() {
@@ -133,9 +139,9 @@ describe("Cross Signing", function() {
error.httpStatus == 401;
throw error;
};
alice.uploadKeySignatures = async () => {};
alice.setAccountData = async () => {};
alice.getAccountDataFromServer = async () => { };
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T extends {[k: string]: any}>(): Promise<T> => ({} as T);
const authUploadDeviceSigningKeys = async func => await func({});
// Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass
@@ -151,14 +157,15 @@ describe("Cross Signing", function() {
}
}
expect(bootstrapDidThrow).toBeTruthy();
alice.stopClient();
});
it("should upload a signature when a user is verified", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key
await resetCrossSigningKeys(alice);
// Alice downloads Bob's device key
@@ -172,16 +179,20 @@ describe("Cross Signing", function() {
},
},
},
firstUse: false,
crossSigningVerifiedBefore: false,
});
// Alice verifies Bob's key
const promise = new Promise((resolve, reject) => {
alice.uploadKeySignatures = (...args) => {
alice.uploadKeySignatures = async (...args) => {
resolve(...args);
return { failures: {} };
};
});
await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true);
// Alice should send a signature of Bob's key to the server
await promise;
alice.stopClient();
});
it.skip("should get cross-signing keys from sync", async function() {
@@ -203,7 +214,7 @@ describe("Cross Signing", function() {
{
cryptoCallbacks: {
// will be called to sign our own device
getCrossSigningKey: type => {
getCrossSigningKey: async type => {
if (type === 'master') {
return masterKey;
} else {
@@ -215,7 +226,7 @@ describe("Cross Signing", function() {
);
const keyChangePromise = new Promise((resolve, reject) => {
alice.once("crossSigning.keysChanged", async (e) => {
alice.once(CryptoEvent.KeysChanged, async (e) => {
resolve(e);
await alice.checkOwnCrossSigningTrust({
allowPrivateKeyRequests: true,
@@ -223,14 +234,14 @@ describe("Cross Signing", function() {
});
});
const uploadSigsPromise = new Promise((resolve, reject) => {
alice.uploadKeySignatures = jest.fn(async (content) => {
const uploadSigsPromise = new Promise<void>((resolve, reject) => {
alice.uploadKeySignatures = jest.fn().mockImplementation(async (content) => {
try {
await olmlib.verifySignature(
alice.crypto.olmDevice,
content["@alice:example.com"][
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
],
],
"@alice:example.com",
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
);
@@ -246,16 +257,22 @@ describe("Cross Signing", function() {
});
});
// @ts-ignore private property
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
.Osborne2;
const aliceDevice = {
user_id: "@alice:example.com",
device_id: "Osborne2",
keys: deviceInfo.keys,
algorithms: deviceInfo.algorithms,
};
aliceDevice.keys = deviceInfo.keys;
aliceDevice.algorithms = deviceInfo.algorithms;
await alice.crypto.signObject(aliceDevice);
olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com");
olmlib.pkSign(
aliceDevice as ISignedKey,
selfSigningKey as unknown as PkSigning,
"@alice:example.com",
'',
);
// feed sync result that includes master key, ssk, device key
const responses = [
@@ -353,14 +370,15 @@ describe("Cross Signing", function() {
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(aliceDeviceTrust.isTofu()).toBeTruthy();
expect(aliceDeviceTrust.isVerified()).toBeTruthy();
alice.stopClient();
});
it("should use trust chain to determine device verification", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key
await resetCrossSigningKeys(alice);
// Alice downloads Bob's ssk and device key
@@ -370,7 +388,7 @@ describe("Cross Signing", function() {
const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK = {
const bobSSK: ICrossSigningKey = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -394,10 +412,10 @@ describe("Cross Signing", function() {
},
self_signing: bobSSK,
},
firstUse: 1,
unsigned: {},
firstUse: true,
crossSigningVerifiedBefore: false,
});
const bobDevice = {
const bobDeviceUnsigned = {
user_id: "@bob:example.com",
device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
@@ -406,11 +424,16 @@ describe("Cross Signing", function() {
"ed25519:Dynabook": "someOtherPubkey",
},
};
const sig = bobSigning.sign(anotherjson.stringify(bobDevice));
bobDevice.signatures = {
"@bob:example.com": {
["ed25519:" + bobPubkey]: sig,
const sig = bobSigning.sign(anotherjson.stringify(bobDeviceUnsigned));
const bobDevice: IDevice = {
...bobDeviceUnsigned,
signatures: {
"@bob:example.com": {
["ed25519:" + bobPubkey]: sig,
},
},
verified: 0,
known: false,
};
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
@@ -425,7 +448,7 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust.isTofu()).toBeTruthy();
// Alice verifies Bob's SSK
alice.uploadKeySignatures = () => {};
alice.uploadKeySignatures = async () => ({ failures: {} });
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true);
// Bob's device key should be trusted
@@ -437,10 +460,11 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy();
expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy();
expect(bobDeviceTrust2.isTofu()).toBeTruthy();
alice.stopClient();
});
it.skip("should trust signatures received from other devices", async function() {
const aliceKeys = {};
const aliceKeys: Record<string, PkSigning> = {};
const { client: alice, httpBackend } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
null,
@@ -448,8 +472,8 @@ describe("Cross Signing", function() {
);
alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com");
alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {};
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key
await resetCrossSigningKeys(alice);
@@ -461,28 +485,29 @@ describe("Cross Signing", function() {
0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f,
]);
const keyChangePromise = new Promise((resolve, reject) => {
alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => {
const keyChangePromise = new Promise<void>((resolve, reject) => {
alice.crypto.deviceList.once(CryptoEvent.UserCrossSigningUpdated, (userId) => {
if (userId === "@bob:example.com") {
resolve();
}
});
});
// @ts-ignore private property
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
.Osborne2;
const aliceDevice = {
user_id: "@alice:example.com",
device_id: "Osborne2",
keys: deviceInfo.keys,
algorithms: deviceInfo.algorithms,
};
aliceDevice.keys = deviceInfo.keys;
aliceDevice.algorithms = deviceInfo.algorithms;
await alice.crypto.signObject(aliceDevice);
const bobOlmAccount = new global.Olm.Account();
bobOlmAccount.create();
const bobKeys = JSON.parse(bobOlmAccount.identity_keys());
const bobDevice = {
const bobDeviceUnsigned = {
user_id: "@bob:example.com",
device_id: "Dynabook",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
@@ -491,15 +516,25 @@ describe("Cross Signing", function() {
"curve25519:Dynabook": bobKeys.curve25519,
},
};
const deviceStr = anotherjson.stringify(bobDevice);
bobDevice.signatures = {
"@bob:example.com": {
"ed25519:Dynabook": bobOlmAccount.sign(deviceStr),
const deviceStr = anotherjson.stringify(bobDeviceUnsigned);
const bobDevice: IDevice = {
...bobDeviceUnsigned,
signatures: {
"@bob:example.com": {
"ed25519:Dynabook": bobOlmAccount.sign(deviceStr),
},
},
verified: 0,
known: false,
};
olmlib.pkSign(bobDevice, selfSigningKey, "@bob:example.com");
olmlib.pkSign(
bobDevice,
selfSigningKey as unknown as PkSigning,
"@bob:example.com",
'',
);
const bobMaster = {
const bobMaster: ICrossSigningKey = {
user_id: "@bob:example.com",
usage: ["master"],
keys: {
@@ -507,7 +542,7 @@ describe("Cross Signing", function() {
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk",
},
};
olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com");
olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", '');
// Alice downloads Bob's keys
// - device key
@@ -600,14 +635,15 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy();
expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy();
expect(bobDeviceTrust.isTofu()).toBeTruthy();
alice.stopClient();
});
it("should dis-trust an unsigned device", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key
await resetCrossSigningKeys(alice);
// Alice downloads Bob's ssk and device key
@@ -618,7 +654,7 @@ describe("Cross Signing", function() {
const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK = {
const bobSSK: ICrossSigningKey = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -642,8 +678,8 @@ describe("Cross Signing", function() {
},
self_signing: bobSSK,
},
firstUse: 1,
unsigned: {},
firstUse: true,
crossSigningVerifiedBefore: false,
});
const bobDevice = {
user_id: "@bob:example.com",
@@ -655,7 +691,7 @@ describe("Cross Signing", function() {
},
};
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
Dynabook: bobDevice as unknown as IDevice,
});
// Bob's device key should be untrusted
const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
@@ -669,14 +705,15 @@ describe("Cross Signing", function() {
const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
expect(bobDeviceTrust2.isVerified()).toBeFalsy();
expect(bobDeviceTrust2.isTofu()).toBeFalsy();
alice.stopClient();
});
it("should dis-trust a user when their ssk changes", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
await resetCrossSigningKeys(alice);
// Alice downloads Bob's keys
const bobMasterSigning = new global.Olm.PkSigning();
@@ -685,7 +722,7 @@ describe("Cross Signing", function() {
const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK = {
const bobSSK: ICrossSigningKey = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -709,10 +746,10 @@ describe("Cross Signing", function() {
},
self_signing: bobSSK,
},
firstUse: 1,
unsigned: {},
firstUse: true,
crossSigningVerifiedBefore: false,
});
const bobDevice = {
const bobDeviceUnsigned = {
user_id: "@bob:example.com",
device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
@@ -721,16 +758,23 @@ describe("Cross Signing", function() {
"ed25519:Dynabook": "someOtherPubkey",
},
};
const bobDeviceString = anotherjson.stringify(bobDevice);
const bobDeviceString = anotherjson.stringify(bobDeviceUnsigned);
const sig = bobSigning.sign(bobDeviceString);
bobDevice.signatures = {};
bobDevice.signatures["@bob:example.com"] = {};
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig;
const bobDevice: IDevice = {
...bobDeviceUnsigned,
verified: 0,
known: false,
signatures: {
"@bob:example.com": {
["ed25519:" + bobPubkey]: sig,
},
},
};
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice,
});
// Alice verifies Bob's SSK
alice.uploadKeySignatures = () => {};
alice.uploadKeySignatures = async () => ({ failures: {} });
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true);
// Bob's device key should be trusted
@@ -745,7 +789,7 @@ describe("Cross Signing", function() {
const bobSigning2 = new global.Olm.PkSigning();
const bobPrivkey2 = bobSigning2.generate_seed();
const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2);
const bobSSK2 = {
const bobSSK2: ICrossSigningKey = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -769,8 +813,8 @@ describe("Cross Signing", function() {
},
self_signing: bobSSK2,
},
firstUse: 0,
unsigned: {},
firstUse: false,
crossSigningVerifiedBefore: false,
});
// Bob's and his device should be untrusted
const bobTrust = alice.checkUserTrust("@bob:example.com");
@@ -782,7 +826,7 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust2.isTofu()).toBeFalsy();
// Alice verifies Bob's SSK
alice.uploadKeySignatures = () => {};
alice.uploadKeySignatures = async () => ({ failures: {} });
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true);
// Bob should be trusted but not his device
@@ -805,6 +849,7 @@ describe("Cross Signing", function() {
const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy();
alice.stopClient();
});
it("should offer to upgrade device verifications to cross-signing", async function() {
@@ -814,20 +859,21 @@ describe("Cross Signing", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
shouldUpgradeDeviceVerifications: (verifs) => {
shouldUpgradeDeviceVerifications: async (verifs) => {
expect(verifs.users["@bob:example.com"]).toBeDefined();
upgradeResolveFunc();
return ["@bob:example.com"];
},
},
},
);
const { client: bob } = await makeTestClient(
{ userId: "@bob:example.com", deviceId: "Dynabook" },
);
bob.uploadDeviceSigningKeys = async () => {};
bob.uploadKeySignatures = async () => {};
bob.uploadDeviceSigningKeys = async () => ({});
bob.uploadKeySignatures = async () => ({ failures: {} });
// set Bob's cross-signing key
await resetCrossSigningKeys(bob);
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
@@ -846,8 +892,8 @@ describe("Cross Signing", function() {
bob.crypto.crossSigningInfo.toStorage(),
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// when alice sets up cross-signing, she should notice that bob's
// cross-signing key is signed by his Dynabook, which alice has
// verified, and ask if the device verification should be upgraded to a
@@ -873,14 +919,159 @@ describe("Cross Signing", function() {
upgradePromise = new Promise((resolve) => {
upgradeResolveFunc = resolve;
});
alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com");
alice.crypto.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, "@bob:example.com");
await new Promise((resolve) => {
alice.crypto.on("userTrustStatusChanged", resolve);
alice.crypto.on(CryptoEvent.UserTrustStatusChanged, resolve);
});
await upgradePromise;
const bobTrust3 = alice.checkUserTrust("@bob:example.com");
expect(bobTrust3.isCrossSigningVerified()).toBeTruthy();
expect(bobTrust3.isTofu()).toBeTruthy();
alice.stopClient();
bob.stopClient();
});
it(
"should observe that our own device is cross-signed, even if this device doesn't trust the key",
async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning();
const aliceMasterPrivkey = aliceMasterSigning.generate_seed();
const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey);
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK: ICrossSigningKey = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
["ed25519:" + alicePubkey]: alicePubkey,
},
};
const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK));
aliceSSK.signatures = {
"@alice:example.com": {
["ed25519:" + aliceMasterPubkey]: sskSig,
},
};
// Alice's device downloads the keys, but doesn't trust them yet
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey,
},
},
self_signing: aliceSSK,
},
firstUse: true,
crossSigningVerifiedBefore: false,
});
// Alice has a second device that's cross-signed
const aliceDeviceId = 'Dynabook';
const aliceUnsignedDevice = {
user_id: "@alice:example.com",
device_id: aliceDeviceId,
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey",
},
};
const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice));
const aliceCrossSignedDevice: IDevice = {
...aliceUnsignedDevice,
verified: 0,
known: false,
signatures: {
"@alice:example.com": {
["ed25519:" + alicePubkey]: sig,
},
} };
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
[aliceDeviceId]: aliceCrossSignedDevice,
});
// We don't trust the cross-signing keys yet...
expect(
alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified(),
).toBeFalsy();
// ... but we do acknowledge that the device is signed by them
expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy();
alice.stopClient();
},
);
it("should observe that our own device isn't cross-signed", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => ({ failures: {} });
// Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning();
const aliceMasterPrivkey = aliceMasterSigning.generate_seed();
const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey);
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK: ICrossSigningKey = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
["ed25519:" + alicePubkey]: alicePubkey,
},
};
const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK));
aliceSSK.signatures = {
"@alice:example.com": {
["ed25519:" + aliceMasterPubkey]: sskSig,
},
};
// Alice's device downloads the keys
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey,
},
},
self_signing: aliceSSK,
},
firstUse: true,
crossSigningVerifiedBefore: false,
});
const deviceId = "Dynabook";
const aliceNotCrossSignedDevice: IDevice = {
verified: 0,
known: false,
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey",
},
};
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
[deviceId]: aliceNotCrossSignedDevice,
});
expect(alice.checkIfOwnDeviceCrossSigned(deviceId)).toBeFalsy();
alice.stopClient();
});
});
@@ -1,11 +1,13 @@
import { IRecoveryKey } from '../../../src/crypto/api';
import { CrossSigningLevel } from '../../../src/crypto/CrossSigning';
import { IndexedDBCryptoStore } from '../../../src/crypto/store/indexeddb-crypto-store';
// needs to be phased out and replaced with bootstrapSecretStorage,
// but that is doing too much extra stuff for it to be an easy transition.
export async function resetCrossSigningKeys(client, {
level,
authUploadDeviceSigningKeys = async func => await func(),
} = {}) {
export async function resetCrossSigningKeys(
client,
{ level }: { level?: CrossSigningLevel} = {},
): Promise<void> {
const crypto = client.crypto;
const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys);
@@ -30,14 +32,14 @@ export async function resetCrossSigningKeys(client, {
await crypto.afterCrossSigningLocalKeyChange();
}
export async function createSecretStorageKey() {
export async function createSecretStorageKey(): Promise<IRecoveryKey> {
const decryption = new global.Olm.PkDecryption();
const storagePublicKey = decryption.generate_key();
const storagePrivateKey = decryption.get_private_key();
decryption.free();
return {
// `pubkey` not used anymore with symmetric 4S
keyInfo: { pubkey: storagePublicKey },
keyInfo: { pubkey: storagePublicKey, key: undefined },
privateKey: storagePrivateKey,
};
}
@@ -43,13 +43,15 @@ const requests = [
describe.each([
["IndexedDBCryptoStore",
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore",
() => new IndexedDBCryptoStore(undefined, "tests")],
() => new IndexedDBCryptoStore(undefined, "tests")],
["MemoryCryptoStore", () => {
const store = new IndexedDBCryptoStore(undefined, "tests");
store._backend = new MemoryCryptoStore();
store._backendPromise = Promise.resolve(store._backend);
// @ts-ignore set private properties
store.backend = new MemoryCryptoStore();
// @ts-ignore
store.backendPromise = Promise.resolve(store.backend);
return store;
}],
])("Outgoing room key requests [%s]", function(name, dbFactory) {
@@ -64,22 +66,22 @@ describe.each([
});
it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state",
async () => {
const r = await
async () => {
const r = await
store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
expect(r).toHaveLength(2);
requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => {
expect(r).toContainEqual(e);
expect(r).toHaveLength(2);
requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => {
expect(r).toContainEqual(e);
});
});
});
test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state",
async () => {
const r =
async () => {
const r =
await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]);
expect(r).not.toBeNull();
expect(r).not.toBeUndefined();
expect(r.state).toEqual(RoomKeyRequestState.Sent);
expect(requests).toContainEqual(r);
});
expect(r).not.toBeNull();
expect(r).not.toBeUndefined();
expect(r.state).toEqual(RoomKeyRequestState.Sent);
expect(requests).toContainEqual(r);
});
});
@@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 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.
@@ -24,15 +24,18 @@ import { encryptAES } from "../../../src/crypto/aes";
import { resetCrossSigningKeys, createSecretStorageKey } from "./crypto-utils";
import { logger } from '../../../src/logger';
import * as utils from "../../../src/utils";
import { ICreateClientOpts } from '../../../src/client';
import { ISecretStorageKeyInfo } from '../../../src/crypto/api';
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto');
utils.setCrypto(crypto);
} catch (err) {
logger.log('nodejs was compiled without crypto support');
}
async function makeTestClient(userInfo, options) {
async function makeTestClient(userInfo: { userId: string, deviceId: string}, options: Partial<ICreateClientOpts> = {}) {
const client = (new TestClient(
userInfo.userId, userInfo.deviceId, undefined, undefined, options,
)).client;
@@ -46,7 +49,7 @@ async function makeTestClient(userInfo, options) {
await client.initCrypto();
// No need to download keys for these tests
client.crypto.downloadKeys = async function() {};
jest.spyOn(client.crypto, 'downloadKeys').mockResolvedValue({});
return client;
}
@@ -54,7 +57,7 @@ async function makeTestClient(userInfo, options) {
// Wrapper around pkSign to return a signed object. pkSign returns the
// signature, rather than the signed object.
function sign(obj, key, userId) {
olmlib.pkSign(obj, key, userId);
olmlib.pkSign(obj, key, userId, '');
return obj;
}
@@ -84,7 +87,7 @@ describe("Secrets", function() {
},
};
const getKey = jest.fn(e => {
const getKey = jest.fn().mockImplementation(async e => {
expect(Object.keys(e.keys)).toEqual(["abc"]);
return ['abc', key];
});
@@ -93,7 +96,7 @@ describe("Secrets", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: t => signingKey,
getCrossSigningKey: async t => signingKey,
getSecretStorageKey: getKey,
},
},
@@ -104,17 +107,19 @@ describe("Secrets", function() {
const secretStorage = alice.crypto.secretStorage;
alice.setAccountData = async function(eventType, contents, callback) {
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: eventType,
content: contents,
}),
]);
if (callback) {
callback();
}
};
jest.spyOn(alice, 'setAccountData').mockImplementation(
async function(eventType, contents, callback) {
alice.store.storeAccountDataEvents([
new MatrixEvent({
type: eventType,
content: contents,
}),
]);
if (callback) {
callback(undefined, undefined);
}
return {};
});
const keyAccountData = {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
@@ -136,6 +141,7 @@ describe("Secrets", function() {
expect(await secretStorage.get("foo")).toBe("bar");
expect(getKey).toHaveBeenCalled();
alice.stopClient();
});
it("should throw if given a key that doesn't exist", async function() {
@@ -150,6 +156,7 @@ describe("Secrets", function() {
expect(true).toBeFalsy();
} catch (e) {
}
alice.stopClient();
});
it("should refuse to encrypt with zero keys", async function() {
@@ -162,12 +169,13 @@ describe("Secrets", function() {
expect(true).toBeFalsy();
} catch (e) {
}
alice.stopClient();
});
it("should encrypt with default key if keys is null", async function() {
const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i;
const getKey = jest.fn(e => {
const getKey = jest.fn().mockImplementation(async e => {
expect(Object.keys(e.keys)).toEqual([newKeyId]);
return [newKeyId, key];
});
@@ -190,11 +198,12 @@ describe("Secrets", function() {
content: contents,
}),
]);
return {};
};
resetCrossSigningKeys(alice);
const { keyId: newKeyId } = await alice.addSecretStorageKey(
SECRET_STORAGE_ALGORITHM_V1_AES,
SECRET_STORAGE_ALGORITHM_V1_AES, { pubkey: undefined, key: undefined },
);
// we don't await on this because it waits for the event to come down the sync
// which won't happen in the test setup
@@ -203,6 +212,7 @@ describe("Secrets", function() {
const accountData = alice.getAccountData('foo');
expect(accountData.getContent().encrypted).toBeTruthy();
alice.stopClient();
});
it("should refuse to encrypt if no keys given and no default key", async function() {
@@ -215,10 +225,11 @@ describe("Secrets", function() {
expect(true).toBeFalsy();
} catch (e) {
}
alice.stopClient();
});
it("should request secrets from other clients", async function() {
const [osborne2, vax] = await makeTestClients(
const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@alice:example.com", deviceId: "VAX" },
@@ -273,6 +284,9 @@ describe("Secrets", function() {
const secret = await request.promise;
expect(secret).toBe("bar");
osborne2.stop();
vax.stop();
clearTestClientTimeouts();
});
describe("bootstrap", function() {
@@ -298,7 +312,7 @@ describe("Secrets", function() {
it("bootstraps when no storage or cross-signing keys locally", async function() {
const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i;
const getKey = jest.fn(e => {
const getKey = jest.fn().mockImplementation(async e => {
return [Object.keys(e.keys)[0], key];
});
@@ -313,8 +327,8 @@ describe("Secrets", function() {
},
},
);
bob.uploadDeviceSigningKeys = async () => {};
bob.uploadKeySignatures = async () => {};
bob.uploadDeviceSigningKeys = async () => ({});
bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined);
bob.setAccountData = async function(eventType, contents, callback) {
const event = new MatrixEvent({
type: eventType,
@@ -324,10 +338,11 @@ describe("Secrets", function() {
event,
]);
this.emit("accountData", event);
return {};
};
await bob.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async func => await func({}),
authUploadDeviceSigningKeys: async func => { await func({}); },
});
await bob.bootstrapSecretStorage({
createSecretStorageKey,
@@ -340,6 +355,7 @@ describe("Secrets", function() {
expect(await crossSigning.isStoredInSecretStorage(secretStorage))
.toBeTruthy();
expect(await secretStorage.hasKey()).toBeTruthy();
bob.stopClient();
});
it("bootstraps when cross-signing keys in secret storage", async function() {
@@ -406,10 +422,11 @@ describe("Secrets", function() {
expect(await crossSigning.isStoredInSecretStorage(secretStorage))
.toBeTruthy();
expect(await secretStorage.hasKey()).toBeTruthy();
bob.stopClient();
});
it("adds passphrase checking if it's lacking", async function() {
let crossSigningKeys = {
let crossSigningKeys: Record<string, Uint8Array> = {
master: XSK,
user_signing: USK,
self_signing: SSK,
@@ -421,9 +438,9 @@ describe("Secrets", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: t => crossSigningKeys[t],
getCrossSigningKey: async t => crossSigningKeys[t],
saveCrossSigningKeys: k => crossSigningKeys = k,
getSecretStorageKey: ({ keys }, name) => {
getSecretStorageKey: async ({ keys }, name) => {
for (const keyId of Object.keys(keys)) {
if (secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]];
@@ -479,6 +496,8 @@ describe("Secrets", function() {
}),
]);
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
firstUse: false,
crossSigningVerifiedBefore: false,
keys: {
master: {
user_id: "@alice:example.com",
@@ -519,14 +538,15 @@ describe("Secrets", function() {
});
alice.store.storeAccountDataEvents([event]);
this.emit("accountData", event);
return {};
};
await alice.bootstrapSecretStorage();
await alice.bootstrapSecretStorage({});
expect(alice.getAccountData("m.secret_storage.default_key").getContent())
.toEqual({ key: "key_id" });
const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")
.getContent();
.getContent() as ISecretStorageKeyInfo;
expect(keyInfo.algorithm)
.toEqual("m.secret_storage.v1.aes-hmac-sha2");
expect(keyInfo.passphrase).toEqual({
@@ -538,9 +558,10 @@ describe("Secrets", function() {
expect(keyInfo).toHaveProperty("mac");
expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo))
.toBeTruthy();
alice.stopClient();
});
it("fixes backup keys in the wrong format", async function() {
let crossSigningKeys = {
let crossSigningKeys: Record<string, Uint8Array> = {
master: XSK,
user_signing: USK,
self_signing: SSK,
@@ -552,9 +573,9 @@ describe("Secrets", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{
cryptoCallbacks: {
getCrossSigningKey: t => crossSigningKeys[t],
getCrossSigningKey: async t => crossSigningKeys[t],
saveCrossSigningKeys: k => crossSigningKeys = k,
getSecretStorageKey: ({ keys }, name) => {
getSecretStorageKey: async ({ keys }, name) => {
for (const keyId of Object.keys(keys)) {
if (secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]];
@@ -619,6 +640,8 @@ describe("Secrets", function() {
}),
]);
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
firstUse: false,
crossSigningVerifiedBefore: false,
keys: {
master: {
user_id: "@alice:example.com",
@@ -659,15 +682,17 @@ describe("Secrets", function() {
});
alice.store.storeAccountDataEvents([event]);
this.emit("accountData", event);
return {};
};
await alice.bootstrapSecretStorage();
await alice.bootstrapSecretStorage({});
const backupKey = alice.getAccountData("m.megolm_backup.v1")
.getContent();
expect(backupKey.encrypted).toHaveProperty("key_id");
expect(await alice.getSecret("m.megolm_backup.v1"))
.toEqual("ey0GB1kB6jhOWgwiBUMIWg==");
alice.stopClient();
});
});
});
@@ -40,7 +40,7 @@ describe("verification request integration tests with crypto layer", function()
});
it("should request and accept a verification", async function() {
const [alice, bob] = await makeTestClients(
const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
@@ -78,5 +78,9 @@ describe("verification request integration tests with crypto layer", function()
// XXX: Private function access (but it's a test, so we're okay)
aliceVerifier.endTimer();
alice.stop();
bob.stop();
clearTestClientTimeouts();
});
});
+70 -4
View File
@@ -75,9 +75,10 @@ describe("SAS verification", function() {
let bobSasEvent;
let aliceVerifier;
let bobPromise;
let clearTestClientTimeouts;
beforeEach(async () => {
[alice, bob] = await makeTestClients(
[[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
@@ -178,6 +179,8 @@ describe("SAS verification", function() {
alice.stop(),
bob.stop(),
]);
clearTestClientTimeouts();
});
it("should verify a key", async () => {
@@ -215,7 +218,7 @@ describe("SAS verification", function() {
]);
// make sure that it uses the preferred method
expect(macMethod).toBe("hkdf-hmac-sha256");
expect(macMethod).toBe("org.matrix.msc3783.hkdf-hmac-sha256");
expect(keyAgreement).toBe("curve25519-hkdf-sha256");
// make sure Alice and Bob verified each other
@@ -227,6 +230,62 @@ describe("SAS verification", function() {
expect(aliceDevice.isVerified()).toBeTruthy();
});
it("should be able to verify using the old base64", async () => {
// pretend that Alice can only understand the old (incorrect) base64
// encoding, and make sure that she can still verify with Bob
let macMethod;
const aliceOrigSendToDevice = alice.client.sendToDevice.bind(alice.client);
alice.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.start") {
// Note: this modifies not only the message that Bob
// receives, but also the copy of the message that Alice
// has, since it is the same object. If this does not
// happen, the verification will fail due to a hash
// commitment mismatch.
map[bob.client.getUserId()][bob.client.deviceId]
.message_authentication_codes = ['hkdf-hmac-sha256'];
}
return aliceOrigSendToDevice(type, map);
};
const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client);
bob.client.sendToDevice = (type, map) => {
if (type === "m.key.verification.accept") {
macMethod = map[alice.client.getUserId()][alice.client.deviceId]
.message_authentication_code;
}
return bobOrigSendToDevice(type, map);
};
alice.httpBackend.when('POST', '/keys/query').respond(200, {
failures: {},
device_keys: {
"@bob:example.com": BOB_DEVICES,
},
});
bob.httpBackend.when('POST', '/keys/query').respond(200, {
failures: {},
device_keys: {
"@alice:example.com": ALICE_DEVICES,
},
});
await Promise.all([
aliceVerifier.verify(),
bobPromise.then((verifier) => verifier.verify()),
alice.httpBackend.flush(),
bob.httpBackend.flush(),
]);
expect(macMethod).toBe("hkdf-hmac-sha256");
const bobDevice
= await alice.client.getStoredDevice("@bob:example.com", "Dynabook");
expect(bobDevice.isVerified()).toBeTruthy();
const aliceDevice
= await bob.client.getStoredDevice("@alice:example.com", "Osborne2");
expect(aliceDevice.isVerified()).toBeTruthy();
});
it("should be able to verify using the old MAC", async () => {
// pretend that Alice can only understand the old (incorrect) MAC,
// and make sure that she can still verify with Bob
@@ -334,7 +393,7 @@ describe("SAS verification", function() {
});
it("should send a cancellation message on error", async function() {
const [alice, bob] = await makeTestClients(
const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
@@ -377,6 +436,10 @@ describe("SAS verification", function() {
.not.toHaveBeenCalled();
expect(bob.client.setDeviceVerified)
.not.toHaveBeenCalled();
alice.stop();
bob.stop();
clearTestClientTimeouts();
});
describe("verification in DM", function() {
@@ -386,9 +449,10 @@ describe("SAS verification", function() {
let bobSasEvent;
let aliceVerifier;
let bobPromise;
let clearTestClientTimeouts;
beforeEach(async function() {
[alice, bob] = await makeTestClients(
[[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
{ userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" },
@@ -488,6 +552,8 @@ describe("SAS verification", function() {
alice.stop(),
bob.stop(),
]);
clearTestClientTimeouts();
});
it("should verify a key", async function() {
+9 -2
View File
@@ -23,6 +23,7 @@ import { logger } from '../../../../src/logger';
export async function makeTestClients(userInfos, options) {
const clients = [];
const timeouts = [];
const clientMap = {};
const sendToDevice = function(type, map) {
// logger.log(this.getUserId(), "sends", type, map);
@@ -66,7 +67,7 @@ export async function makeTestClients(userInfos, options) {
},
}));
setImmediate(() => {
const timeout = setTimeout(() => {
for (const tc of clients) {
if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this
logger.log("sending remote echo!!");
@@ -77,6 +78,8 @@ export async function makeTestClients(userInfos, options) {
}
});
timeouts.push(timeout);
return Promise.resolve({ event_id: eventId });
};
@@ -103,7 +106,11 @@ export async function makeTestClients(userInfos, options) {
await Promise.all(clients.map((testClient) => testClient.client.initCrypto()));
return clients;
const destroy = () => {
timeouts.forEach((t) => clearTimeout(t));
};
return [clients, destroy];
}
export function setupWebcrypto() {
+4
View File
@@ -44,6 +44,10 @@ describe("eventMapperFor", function() {
rooms = [];
});
afterEach(() => {
client.stopClient();
});
it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => {
const roomId = "!room:example.org";
const room = new Room(roomId, client, userId);
+294
View File
@@ -0,0 +1,294 @@
/*
Copyright 2022 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 * as utils from "../test-utils/test-utils";
import {
EventTimeline,
EventTimelineSet,
EventType,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
DuplicateStrategy,
} from '../../src';
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
describe('EventTimelineSet', () => {
const roomId = '!foo:bar';
const userA = "@alice:bar";
let room: Room;
let eventTimeline: EventTimeline;
let eventTimelineSet: EventTimelineSet;
let client: MatrixClient;
let messageEvent: MatrixEvent;
let replyEvent: MatrixEvent;
const itShouldReturnTheRelatedEvents = () => {
it('should return the related events', () => {
eventTimelineSet.relations.aggregateChildEvent(messageEvent);
const relations = eventTimelineSet.relations.getChildEventsForEvent(
messageEvent.getId(),
"m.in_reply_to",
EventType.RoomMessage,
);
expect(relations).toBeDefined();
expect(relations.getRelations().length).toBe(1);
expect(relations.getRelations()[0].getId()).toBe(replyEvent.getId());
});
};
beforeEach(() => {
client = utils.mock(MatrixClient, 'MatrixClient');
client.reEmitter = utils.mock(ReEmitter, 'ReEmitter');
room = new Room(roomId, client, userA);
eventTimelineSet = new EventTimelineSet(room);
eventTimeline = new EventTimeline(eventTimelineSet);
messageEvent = utils.mkMessage({
room: roomId,
user: userA,
msg: 'Hi!',
event: true,
});
replyEvent = utils.mkReplyMessage({
room: roomId,
user: userA,
msg: 'Hoo!',
event: true,
replyToMessage: messageEvent,
});
});
describe('addLiveEvent', () => {
it("Adds event to the live timeline in the timeline set", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addLiveEvent(messageEvent);
expect(liveTimeline.getEvents().length).toStrictEqual(1);
});
it("should replace a timeline event if dupe strategy is 'replace'", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addLiveEvent(messageEvent, {
duplicateStrategy: DuplicateStrategy.Replace,
});
expect(liveTimeline.getEvents().length).toStrictEqual(1);
// make a duplicate
const duplicateMessageEvent = utils.mkMessage({
room: roomId, user: userA, msg: "dupe", event: true,
});
duplicateMessageEvent.event.event_id = messageEvent.getId();
// Adding the duplicate event should replace the `messageEvent`
// because it has the same `event_id` and duplicate strategy is
// replace.
eventTimelineSet.addLiveEvent(duplicateMessageEvent, {
duplicateStrategy: DuplicateStrategy.Replace,
});
const eventsInLiveTimeline = liveTimeline.getEvents();
expect(eventsInLiveTimeline.length).toStrictEqual(1);
expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent);
});
it("Make sure legacy overload passing options directly as parameters still works", () => {
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow();
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow();
});
});
describe('addEventToTimeline', () => {
it("Adds event to timeline", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents().length).toStrictEqual(1);
});
it("Make sure legacy overload passing options directly as parameters still works", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(() => {
eventTimelineSet.addEventToTimeline(
messageEvent,
liveTimeline,
true,
);
}).not.toThrow();
expect(() => {
eventTimelineSet.addEventToTimeline(
messageEvent,
liveTimeline,
true,
false,
);
}).not.toThrow();
});
});
describe('aggregateRelations', () => {
describe('with unencrypted events', () => {
beforeEach(() => {
eventTimelineSet.addEventsToTimeline(
[
messageEvent,
replyEvent,
],
true,
eventTimeline,
'foo',
);
});
itShouldReturnTheRelatedEvents();
});
describe('with events to be decrypted', () => {
let messageEventShouldAttemptDecryptionSpy: jest.SpyInstance;
let messageEventIsDecryptionFailureSpy: jest.SpyInstance;
let replyEventShouldAttemptDecryptionSpy: jest.SpyInstance;
let replyEventIsDecryptionFailureSpy: jest.SpyInstance;
beforeEach(() => {
messageEventShouldAttemptDecryptionSpy = jest.spyOn(messageEvent, 'shouldAttemptDecryption');
messageEventShouldAttemptDecryptionSpy.mockReturnValue(true);
messageEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure');
replyEventShouldAttemptDecryptionSpy = jest.spyOn(replyEvent, 'shouldAttemptDecryption');
replyEventShouldAttemptDecryptionSpy.mockReturnValue(true);
replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure');
eventTimelineSet.addEventsToTimeline(
[
messageEvent,
replyEvent,
],
true,
eventTimeline,
'foo',
);
});
it('should not return the related events', () => {
eventTimelineSet.relations.aggregateChildEvent(messageEvent);
const relations = eventTimelineSet.relations.getChildEventsForEvent(
messageEvent.getId(),
"m.in_reply_to",
EventType.RoomMessage,
);
expect(relations).toBeUndefined();
});
describe('after decryption', () => {
beforeEach(() => {
// simulate decryption failure once
messageEventIsDecryptionFailureSpy.mockReturnValue(true);
replyEventIsDecryptionFailureSpy.mockReturnValue(true);
messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent);
replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent);
// simulate decryption
messageEventIsDecryptionFailureSpy.mockReturnValue(false);
replyEventIsDecryptionFailureSpy.mockReturnValue(false);
messageEventShouldAttemptDecryptionSpy.mockReturnValue(false);
replyEventShouldAttemptDecryptionSpy.mockReturnValue(false);
messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent);
replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent);
});
itShouldReturnTheRelatedEvents();
});
});
});
describe("canContain", () => {
const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
"body": "Thread response :: " + Math.random(),
"m.relates_to": {
"event_id": root.getId(),
"m.in_reply_to": {
"event_id": root.getId(),
},
"rel_type": "m.thread",
},
},
}, room.client);
let thread: Thread;
beforeEach(() => {
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
it("should throw if timeline set has no room", () => {
const eventTimelineSet = new EventTimelineSet(undefined, {}, client);
expect(() => eventTimelineSet.canContain(messageEvent)).toThrowError();
});
it("should return false if timeline set is for thread but event is not threaded", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
expect(eventTimelineSet.canContain(replyEvent)).toBeFalsy();
});
it("should return false if timeline set it for thread but event it for a different thread", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
const event = mkThreadResponse(replyEvent);
expect(eventTimelineSet.canContain(event)).toBeFalsy();
});
it("should return false if timeline set is not for a thread but event is a thread response", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client);
const event = mkThreadResponse(replyEvent);
expect(eventTimelineSet.canContain(event)).toBeFalsy();
});
it("should return true if the timeline set is not for a thread and the event is a thread root", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client);
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
});
it("should return true if the timeline set is for a thread and the event is its thread root", () => {
const thread = new Thread(messageEvent.getId(), messageEvent, { room, client });
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
messageEvent.setThread(thread);
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
});
it("should return true if the timeline set is for a thread and the event is a response to it", () => {
const thread = new Thread(messageEvent.getId(), messageEvent, { room, client });
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
messageEvent.setThread(thread);
const event = mkThreadResponse(messageEvent);
expect(eventTimelineSet.canContain(event)).toBeTruthy();
});
});
});
@@ -1,26 +1,36 @@
import { mocked } from 'jest-mock';
import * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/models/event-timeline";
import { RoomState } from "../../src/models/room-state";
import { MatrixClient } from "../../src/matrix";
import { Room } from "../../src/models/room";
import { RoomMember } from "../../src/models/room-member";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
function mockRoomStates(timeline) {
timeline.startState = utils.mock(RoomState, "startState");
timeline.endState = utils.mock(RoomState, "endState");
}
jest.mock("../../src/models/room-state");
describe("EventTimeline", function() {
const roomId = "!foo:bar";
const userA = "@alice:bar";
const userB = "@bertha:bar";
let timeline;
let timeline: EventTimeline;
const mockClient = {} as unknown as MatrixClient;
const getTimeline = (): EventTimeline => {
const room = new Room(roomId, mockClient, userA);
const timelineSet = new EventTimelineSet(room);
jest.spyOn(timelineSet.room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet);
return new EventTimeline(timelineSet);
};
beforeEach(function() {
// XXX: this is a horrid hack; should use sinon or something instead to mock
const timelineSet = { room: { roomId: roomId } };
timelineSet.room.getUnfilteredTimelineSet = function() {
return timelineSet;
};
// reset any RoomState mocks
jest.resetAllMocks();
timeline = new EventTimeline(timelineSet);
timeline = getTimeline();
});
describe("construction", function() {
@@ -31,10 +41,6 @@ describe("EventTimeline", function() {
});
describe("initialiseState", function() {
beforeEach(function() {
mockRoomStates(timeline);
});
it("should copy state events to start and end state", function() {
const events = [
utils.mkMembership({
@@ -48,11 +54,17 @@ describe("EventTimeline", function() {
}),
];
timeline.initialiseState(events);
expect(timeline.startState.setStateEvents).toHaveBeenCalledWith(
// @ts-ignore private prop
const timelineStartState = timeline.startState;
expect(mocked(timelineStartState).setStateEvents).toHaveBeenCalledWith(
events,
{ timelineWasEmpty: undefined },
);
expect(timeline.endState.setStateEvents).toHaveBeenCalledWith(
// @ts-ignore private prop
const timelineEndState = timeline.endState;
expect(mocked(timelineEndState).setStateEvents).toHaveBeenCalledWith(
events,
{ timelineWasEmpty: undefined },
);
});
@@ -73,7 +85,7 @@ describe("EventTimeline", function() {
expect(function() {
timeline.initialiseState(state);
}).not.toThrow();
timeline.addEvent(event, false);
timeline.addEvent(event, { toStartOfTimeline: false });
expect(function() {
timeline.initialiseState(state);
}).toThrow();
@@ -101,8 +113,8 @@ describe("EventTimeline", function() {
});
it("setNeighbouringTimeline should set neighbour", function() {
const prev = { a: "a" };
const next = { b: "b" };
const prev = getTimeline();
const next = getTimeline();
timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS);
timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS);
expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(prev);
@@ -110,8 +122,8 @@ describe("EventTimeline", function() {
});
it("setNeighbouringTimeline should throw if called twice", function() {
const prev = { a: "a" };
const next = { b: "b" };
const prev = getTimeline();
const next = getTimeline();
expect(function() {
timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS);
}).not.toThrow();
@@ -133,10 +145,6 @@ describe("EventTimeline", function() {
});
describe("addEvent", function() {
beforeEach(function() {
mockRoomStates(timeline);
});
const events = [
utils.mkMessage({
room: roomId, user: userA, msg: "hungry hungry hungry",
@@ -149,9 +157,9 @@ describe("EventTimeline", function() {
];
it("should be able to add events to the end", function() {
timeline.addEvent(events[0], false);
timeline.addEvent(events[0], { toStartOfTimeline: false });
const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], false);
timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
expect(timeline.getEvents()[0]).toEqual(events[0]);
@@ -159,9 +167,9 @@ describe("EventTimeline", function() {
});
it("should be able to add events to the start", function() {
timeline.addEvent(events[0], true);
timeline.addEvent(events[0], { toStartOfTimeline: true });
const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], true);
timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getBaseIndex()).toEqual(initialIndex + 1);
expect(timeline.getEvents().length).toEqual(2);
expect(timeline.getEvents()[0]).toEqual(events[1]);
@@ -169,24 +177,22 @@ describe("EventTimeline", function() {
});
it("should set event.sender for new and old events", function() {
const sentinel = {
userId: userA,
membership: "join",
name: "Alice",
};
const oldSentinel = {
userId: userA,
membership: "join",
name: "Old Alice",
};
timeline.getState(EventTimeline.FORWARDS).getSentinelMember
const sentinel = new RoomMember(roomId, userA);
sentinel.name = "Alice";
sentinel.membership = "join";
const oldSentinel = new RoomMember(roomId, userA);
sentinel.name = "Old Alice";
sentinel.membership = "join";
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
timeline.getState(EventTimeline.BACKWARDS).getSentinelMember
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
@@ -203,50 +209,48 @@ describe("EventTimeline", function() {
content: { name: "Old Room Name" },
});
timeline.addEvent(newEv, false);
timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.sender).toEqual(sentinel);
timeline.addEvent(oldEv, true);
timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.sender).toEqual(oldSentinel);
});
it("should set event.target for new and old m.room.member events",
function() {
const sentinel = {
userId: userA,
membership: "join",
name: "Alice",
};
const oldSentinel = {
userId: userA,
membership: "join",
name: "Old Alice",
};
timeline.getState(EventTimeline.FORWARDS).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
timeline.getState(EventTimeline.BACKWARDS).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
}
return null;
});
function() {
const sentinel = new RoomMember(roomId, userA);
sentinel.name = "Alice";
sentinel.membership = "join";
const newEv = utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
const oldSentinel = new RoomMember(roomId, userA);
sentinel.name = "Old Alice";
sentinel.membership = "join";
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
}
return null;
});
const newEv = utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
});
const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
});
timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.target).toEqual(sentinel);
timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.target).toEqual(oldSentinel);
});
const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
});
timeline.addEvent(newEv, false);
expect(newEv.target).toEqual(sentinel);
timeline.addEvent(oldEv, true);
expect(oldEv.target).toEqual(oldSentinel);
});
it("should call setStateEvents on the right RoomState with the right " +
"forwardLooking value for new events", function() {
@@ -262,13 +266,13 @@ describe("EventTimeline", function() {
}),
];
timeline.addEvent(events[0], false);
timeline.addEvent(events[1], false);
timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
toHaveBeenCalledWith([events[0]]);
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
toHaveBeenCalledWith([events[1]]);
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true);
@@ -291,13 +295,13 @@ describe("EventTimeline", function() {
}),
];
timeline.addEvent(events[0], true);
timeline.addEvent(events[1], true);
timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
toHaveBeenCalledWith([events[0]]);
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
toHaveBeenCalledWith([events[1]]);
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false);
@@ -305,6 +309,15 @@ describe("EventTimeline", function() {
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
not.toHaveBeenCalled();
});
it("Make sure legacy overload passing options directly as parameters still works", () => {
expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow();
// @ts-ignore stateContext is not a valid param
expect(() => timeline.addEvent(events[0], { stateContext: new RoomState(roomId) })).not.toThrow();
expect(() => timeline.addEvent(events[0],
{ toStartOfTimeline: false, roomState: new RoomState(roomId) },
)).not.toThrow();
});
});
describe("removeEvent", function() {
@@ -324,8 +337,8 @@ describe("EventTimeline", function() {
];
it("should remove events", function() {
timeline.addEvent(events[0], false);
timeline.addEvent(events[1], false);
timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getEvents().length).toEqual(2);
let ev = timeline.removeEvent(events[0].getId());
@@ -338,9 +351,9 @@ describe("EventTimeline", function() {
});
it("should update baseIndex", function() {
timeline.addEvent(events[0], false);
timeline.addEvent(events[1], true);
timeline.addEvent(events[2], false);
timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], { toStartOfTimeline: true });
timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getEvents().length).toEqual(3);
expect(timeline.getBaseIndex()).toEqual(1);
@@ -357,14 +370,14 @@ describe("EventTimeline", function() {
// - removing the last event got baseIndex into such a state that
// further addEvent(ev, false) calls made the index increase.
it("should not make baseIndex assplode when removing the last event",
function() {
timeline.addEvent(events[0], true);
timeline.removeEvent(events[0].getId());
const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], false);
timeline.addEvent(events[2], false);
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
});
function() {
timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.removeEvent(events[0].getId());
const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], { toStartOfTimeline: false });
timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2);
});
});
});
@@ -1,6 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 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.
@@ -15,16 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../src/logger";
import { MatrixEvent } from "../../src/models/event";
describe("MatrixEvent", () => {
describe(".attemptDecryption", () => {
let encryptedEvent;
const eventId = 'test_encrypted_event';
beforeEach(() => {
encryptedEvent = new MatrixEvent({
id: 'test_encrypted_event',
event_id: eventId,
type: 'm.room.encrypted',
content: {
ciphertext: 'secrets',
@@ -32,45 +32,34 @@ describe("MatrixEvent", () => {
});
});
it('should retry decryption if a retry is queued', () => {
let callCount = 0;
let prom2;
let prom2Fulfilled = false;
it('should retry decryption if a retry is queued', async () => {
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, 'attemptDecryption');
const crypto = {
decryptEvent: function() {
++callCount;
logger.log(`decrypt: ${callCount}`);
if (callCount == 1) {
decryptEvent: jest.fn()
.mockImplementationOnce(() => {
// schedule a second decryption attempt while
// the first one is still running.
prom2 = encryptedEvent.attemptDecryption(crypto);
prom2.then(() => prom2Fulfilled = true);
encryptedEvent.attemptDecryption(crypto);
const error = new Error("nope");
error.name = 'DecryptionError';
return Promise.reject(error);
} else {
expect(prom2Fulfilled).toBe(
false, 'second attemptDecryption resolved too soon');
})
.mockImplementationOnce(() => {
return Promise.resolve({
clearEvent: {
type: 'm.room.message',
},
});
}
},
}),
};
return encryptedEvent.attemptDecryption(crypto).then(() => {
expect(callCount).toEqual(2);
expect(encryptedEvent.getType()).toEqual('m.room.message');
await encryptedEvent.attemptDecryption(crypto);
// make sure the second attemptDecryption resolves
return prom2;
});
expect(eventAttemptDecryptionSpy).toHaveBeenCalledTimes(2);
expect(crypto.decryptEvent).toHaveBeenCalledTimes(2);
expect(encryptedEvent.getType()).toEqual('m.room.message');
});
});
});
+9 -12
View File
@@ -1,7 +1,4 @@
import {
MatrixEvent,
RelationType,
} from "../../src";
import { RelationType } from "../../src";
import { FilterComponent } from "../../src/filter-component";
import { mkEvent } from '../test-utils/test-utils';
@@ -14,7 +11,7 @@ describe("Filter Component", function() {
content: { },
room: 'roomId',
event: true,
}) as MatrixEvent;
});
const checkResult = filter.check(event);
@@ -28,7 +25,7 @@ describe("Filter Component", function() {
content: { },
room: 'roomId',
event: true,
}) as MatrixEvent;
});
const checkResult = filter.check(event);
@@ -55,7 +52,7 @@ describe("Filter Component", function() {
},
},
},
}) as MatrixEvent;
});
expect(filter.check(threadRootNotParticipated)).toBe(false);
});
@@ -80,7 +77,7 @@ describe("Filter Component", function() {
user: '@someone-else:server.org',
room: 'roomId',
event: true,
}) as MatrixEvent;
});
expect(filter.check(threadRootParticipated)).toBe(true);
});
@@ -100,7 +97,7 @@ describe("Filter Component", function() {
[RelationType.Reference]: {},
},
},
}) as MatrixEvent;
});
expect(filter.check(referenceRelationEvent)).toBe(false);
});
@@ -123,7 +120,7 @@ describe("Filter Component", function() {
},
room: 'roomId',
event: true,
}) as MatrixEvent;
});
const eventWithMultipleRelations = mkEvent({
"type": "m.room.message",
@@ -148,7 +145,7 @@ describe("Filter Component", function() {
},
"room": 'roomId',
"event": true,
}) as MatrixEvent;
});
const noMatchEvent = mkEvent({
"type": "m.room.message",
@@ -160,7 +157,7 @@ describe("Filter Component", function() {
},
"room": 'roomId',
"event": true,
}) as MatrixEvent;
});
expect(filter.check(threadRootEvent)).toBe(true);
expect(filter.check(eventWithMultipleRelations)).toBe(true);
@@ -1,9 +1,9 @@
import { Filter } from "../../src/filter";
import { Filter, IFilterDefinition } from "../../src/filter";
describe("Filter", function() {
const filterId = "f1lt3ring15g00d4ursoul";
const userId = "@sir_arthur_david:humming.tiger";
let filter;
let filter: Filter;
beforeEach(function() {
filter = new Filter(userId);
@@ -37,7 +37,7 @@ describe("Filter", function() {
describe("setDefinition/getDefinition", function() {
it("should set and get the filter body", function() {
const definition = {
event_format: "client",
event_format: "client" as IFilterDefinition['event_format'],
};
filter.setDefinition(definition);
expect(filter.getDefinition()).toEqual(definition);
-175
View File
@@ -1,175 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../src/logger";
import { InteractiveAuth } from "../../src/interactive-auth";
import { MatrixError } from "../../src/http-api";
// Trivial client object to test interactive auth
// (we do not need TestClient here)
class FakeClient {
generateClientSecret() {
return "testcl1Ent5EcreT";
}
}
describe("InteractiveAuth", function() {
it("should start an auth stage and complete it", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
authData: {
session: "sessionId",
flows: [
{ stages: ["logintype"] },
],
params: {
"logintype": { param: "aa" },
},
},
});
expect(ia.getSessionId()).toEqual("sessionId");
expect(ia.getStageParams("logintype")).toEqual({
param: "aa",
});
// first we expect a call here
stateUpdated.mockImplementation(function(stage) {
logger.log('aaaa');
expect(stage).toEqual("logintype");
ia.submitAuthDict({
type: "logintype",
foo: "bar",
});
});
// .. which should trigger a call here
const requestRes = { "a": "b" };
doRequest.mockImplementation(function(authData) {
logger.log('cccc');
expect(authData).toEqual({
session: "sessionId",
type: "logintype",
foo: "bar",
});
return Promise.resolve(requestRes);
});
return ia.attemptAuth().then(function(res) {
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(1);
expect(stateUpdated).toBeCalledTimes(1);
});
});
it("should make a request if no authdata is provided", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
stateUpdated: stateUpdated,
doRequest: doRequest,
});
expect(ia.getSessionId()).toBe(undefined);
expect(ia.getStageParams("logintype")).toBe(undefined);
// first we expect a call to doRequest
doRequest.mockImplementation(function(authData) {
logger.log("request1", authData);
expect(authData).toEqual(null); // first request should be null
const err = new MatrixError({
session: "sessionId",
flows: [
{ stages: ["logintype"] },
],
params: {
"logintype": { param: "aa" },
},
});
err.httpStatus = 401;
throw err;
});
// .. which should be followed by a call to stateUpdated
const requestRes = { "a": "b" };
stateUpdated.mockImplementation(function(stage) {
expect(stage).toEqual("logintype");
expect(ia.getSessionId()).toEqual("sessionId");
expect(ia.getStageParams("logintype")).toEqual({
param: "aa",
});
// submitAuthDict should trigger another call to doRequest
doRequest.mockImplementation(function(authData) {
logger.log("request2", authData);
expect(authData).toEqual({
session: "sessionId",
type: "logintype",
foo: "bar",
});
return Promise.resolve(requestRes);
});
ia.submitAuthDict({
type: "logintype",
foo: "bar",
});
});
return ia.attemptAuth().then(function(res) {
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(2);
expect(stateUpdated).toBeCalledTimes(1);
});
});
it("should start an auth stage and reject if no auth flow", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
});
doRequest.mockImplementation(function(authData) {
logger.log("request1", authData);
expect(authData).toEqual(null); // first request should be null
const err = new MatrixError({
session: "sessionId",
flows: [],
params: {
"logintype": { param: "aa" },
},
});
err.httpStatus = 401;
throw err;
});
return ia.attemptAuth().catch(function(error) {
expect(error.message).toBe('No appropriate authentication flow found');
});
});
});
+284
View File
@@ -0,0 +1,284 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "../../src/client";
import { logger } from "../../src/logger";
import { InteractiveAuth, AuthType } from "../../src/interactive-auth";
import { MatrixError } from "../../src/http-api";
import { sleep } from "../../src/utils";
import { randomString } from "../../src/randomstring";
// Trivial client object to test interactive auth
// (we do not need TestClient here)
class FakeClient {
generateClientSecret() {
return "testcl1Ent5EcreT";
}
}
const getFakeClient = (): MatrixClient => new FakeClient() as unknown as MatrixClient;
describe("InteractiveAuth", function() {
it("should start an auth stage and complete it", function() {
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.Password] },
],
params: {
[AuthType.Password]: { param: "aa" },
},
},
});
expect(ia.getSessionId()).toEqual("sessionId");
expect(ia.getStageParams(AuthType.Password)).toEqual({
param: "aa",
});
// first we expect a call here
stateUpdated.mockImplementation(function(stage) {
logger.log('aaaa');
expect(stage).toEqual(AuthType.Password);
ia.submitAuthDict({
type: AuthType.Password,
});
});
// .. which should trigger a call here
const requestRes = { "a": "b" };
doRequest.mockImplementation(function(authData) {
logger.log('cccc');
expect(authData).toEqual({
session: "sessionId",
type: AuthType.Password,
});
return Promise.resolve(requestRes);
});
return ia.attemptAuth().then(function(res) {
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(1);
expect(stateUpdated).toBeCalledTimes(1);
});
});
it("should make a request if no authdata is provided", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
stateUpdated,
doRequest,
requestEmailToken,
});
expect(ia.getSessionId()).toBe(undefined);
expect(ia.getStageParams(AuthType.Password)).toBe(undefined);
// first we expect a call to doRequest
doRequest.mockImplementation(function(authData) {
logger.log("request1", authData);
expect(authData).toEqual(null); // first request should be null
const err = new MatrixError({
session: "sessionId",
flows: [
{ stages: [AuthType.Password] },
],
params: {
[AuthType.Password]: { param: "aa" },
},
});
err.httpStatus = 401;
throw err;
});
// .. which should be followed by a call to stateUpdated
const requestRes = { "a": "b" };
stateUpdated.mockImplementation(function(stage) {
expect(stage).toEqual(AuthType.Password);
expect(ia.getSessionId()).toEqual("sessionId");
expect(ia.getStageParams(AuthType.Password)).toEqual({
param: "aa",
});
// submitAuthDict should trigger another call to doRequest
doRequest.mockImplementation(function(authData) {
logger.log("request2", authData);
expect(authData).toEqual({
session: "sessionId",
type: AuthType.Password,
});
return Promise.resolve(requestRes);
});
ia.submitAuthDict({
type: AuthType.Password,
});
});
return ia.attemptAuth().then(function(res) {
expect(res).toBe(requestRes);
expect(doRequest).toBeCalledTimes(2);
expect(stateUpdated).toBeCalledTimes(1);
});
});
it("should start an auth stage and reject if no auth flow", function() {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest,
stateUpdated,
requestEmailToken,
});
doRequest.mockImplementation(function(authData) {
logger.log("request1", authData);
expect(authData).toEqual(null); // first request should be null
const err = new MatrixError({
session: "sessionId",
flows: [],
params: {
[AuthType.Password]: { param: "aa" },
},
});
err.httpStatus = 401;
throw err;
});
return ia.attemptAuth().catch(function(error) {
expect(error.message).toBe('No appropriate authentication flow found');
});
});
describe("requestEmailToken", () => {
it("increases auth attempts", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
});
it("increases auth attempts", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
});
it("passes errors through", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => {
throw new Error("unspecific network error");
});
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
expect(async () => await ia.requestEmailToken()).rejects.toThrowError("unspecific network error");
});
it("only starts one request at a time", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(() => sleep(500, { sid: "" }));
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await Promise.all([ia.requestEmailToken(), ia.requestEmailToken(), ia.requestEmailToken()]);
expect(requestEmailToken).toHaveBeenCalledTimes(1);
});
it("stores result in email sid", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
const sid = randomString(24);
requestEmailToken.mockImplementation(() => sleep(500, { sid }));
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await ia.requestEmailToken();
expect(ia.getEmailSid()).toEqual(sid);
});
});
});
@@ -1,7 +1,7 @@
import { TestClient } from '../TestClient';
describe('Login request', function() {
let client;
let client: TestClient;
beforeEach(function() {
client = new TestClient();
+423 -39
View File
@@ -14,8 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { logger } from "../../src/logger";
import { MatrixClient } from "../../src/client";
import { MatrixClient, ClientEvent } from "../../src/client";
import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
@@ -27,15 +29,24 @@ import {
UNSTABLE_MSC3089_TREE_SUBTYPE,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
import { ReceiptType } from "../../src/@types/read_receipts";
import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { Room } from "../../src";
import { ContentHelpers, Room } from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
jest.useFakeTimers();
jest.mock("../../src/webrtc/call", () => ({
...jest.requireActual("../../src/webrtc/call"),
supportsMatrixCall: jest.fn(() => false),
}));
describe("MatrixClient", function() {
const userId = "@alice:bar";
const identityServerUrl = "https://identity.server";
@@ -85,11 +96,16 @@ describe("MatrixClient", function() {
// }
// items are popped off when processed and block if no items left.
];
let acceptKeepalives;
let acceptKeepalives: boolean;
let pendingLookup = null;
function httpReq(cb, method, path, qp, data, prefix) {
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
return Promise.resolve();
return Promise.resolve({
unstable_features: {
"org.matrix.msc3440.stable": true,
},
versions: ["r0.6.0", "r0.6.1"],
});
}
const next = httpLookups.shift();
const logLine = (
@@ -111,6 +127,7 @@ describe("MatrixClient", function() {
method: method,
path: path,
};
pendingLookup.promise.abort = () => {}; // to make it a valid IAbortablePromise
return pendingLookup.promise;
}
if (next.path === path && next.method === method) {
@@ -119,7 +136,7 @@ describe("MatrixClient", function() {
(next.error ? "BAD" : "GOOD") + " response",
);
if (next.expectBody) {
expect(next.expectBody).toEqual(data);
expect(data).toEqual(next.expectBody);
}
if (next.expectQueryParams) {
Object.keys(next.expectQueryParams).forEach(function(k) {
@@ -143,10 +160,32 @@ describe("MatrixClient", function() {
}
return Promise.resolve(next.data);
}
// Jest doesn't let us have custom expectation errors, so if you're seeing this then
// you forgot to handle at least 1 pending request. Check your tests to ensure your
// number of expectations lines up with your number of requests made, and that those
// requests match your expectations.
expect(true).toBe(false);
return new Promise(() => {});
}
function makeClient() {
client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
accessToken: "my.access.token",
request: function() {} as any, // NOP
store: store,
scheduler: scheduler,
userId: userId,
});
// FIXME: We shouldn't be yanking http like this.
client.http = [
"authedRequest", "getContentUri", "request", "uploadContent",
].reduce((r, k) => { r[k] = jest.fn(); return r; }, {});
client.http.authedRequest.mockImplementation(httpReq);
client.http.request.mockImplementation(httpReq);
}
beforeEach(function() {
scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
@@ -164,21 +203,7 @@ 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));
client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
accessToken: "my.access.token",
request: function() {} as any, // NOP
store: store,
scheduler: scheduler,
userId: userId,
});
// FIXME: We shouldn't be yanking http like this.
client.http = [
"authedRequest", "getContentUri", "request", "uploadContent",
].reduce((r, k) => { r[k] = jest.fn(); return r; }, {});
client.http.authedRequest.mockImplementation(httpReq);
client.http.request.mockImplementation(httpReq);
makeClient();
// set reasonable working defaults
acceptKeepalives = true;
@@ -198,6 +223,7 @@ describe("MatrixClient", function() {
client.http.authedRequest.mockImplementation(function() {
return new Promise(() => {});
});
client.stopClient();
});
it("should create (unstable) file trees", async () => {
@@ -718,18 +744,16 @@ describe("MatrixClient", function() {
});
describe("guest rooms", function() {
it("should only do /sync calls (without filter/pushrules)", function(done) {
httpLookups = []; // no /pushrules or /filterw
it("should only do /sync calls (without filter/pushrules)", async function() {
httpLookups = []; // no /pushrules or /filter
httpLookups.push({
method: "GET",
path: "/sync",
data: SYNC_DATA,
thenCall: function() {
done();
},
});
client.setGuest(true);
client.startClient();
await client.startClient();
expect(httpLookups.length).toBe(0);
});
xit("should be able to peek into a room using peekInRoom", function(done) {
@@ -766,7 +790,7 @@ describe("MatrixClient", function() {
expectBody: content,
}];
await client.sendEvent(roomId, EventType.RoomMessage, content, txnId);
await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId);
});
it("overload with null threadId works", async () => {
@@ -779,20 +803,99 @@ describe("MatrixClient", function() {
expectBody: content,
}];
await client.sendEvent(roomId, null, EventType.RoomMessage, content, txnId);
await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId);
});
it("overload with threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
const threadId = "$threadId:server";
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: content,
expectBody: {
...content,
"m.relates_to": {
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, "$threadId:server", EventType.RoomMessage, content, txnId);
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: threadId,
},
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing with reply", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const content = {
body,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
},
};
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
"event_id": threadId,
"is_falling_back": false,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
});
@@ -810,11 +913,12 @@ describe("MatrixClient", function() {
}
},
},
threads: {
get: jest.fn(),
},
getThread: jest.fn(),
addPendingEvent: jest.fn(),
updatePendingEvent: jest.fn(),
reEmitter: {
reEmit: jest.fn(),
},
};
beforeEach(() => {
@@ -918,6 +1022,7 @@ describe("MatrixClient", function() {
};
client.crypto = { // mock crypto
encryptEvent: (event, room) => new Promise(() => {}),
stop: jest.fn(),
};
});
@@ -990,6 +1095,46 @@ describe("MatrixClient", function() {
});
});
describe("read-markers and read-receipts", () => {
it("setRoomReadMarkers", () => {
client.setRoomReadMarkersHttpRequest = jest.fn();
const room = {
hasPendingEvent: jest.fn().mockReturnValue(false),
addLocalEchoReceipt: jest.fn(),
};
const rrEvent = new MatrixEvent({ event_id: "read_event_id" });
const rpEvent = new MatrixEvent({ event_id: "read_private_event_id" });
client.getRoom = () => room;
client.setRoomReadMarkers(
"room_id",
"read_marker_event_id",
rrEvent,
rpEvent,
);
expect(client.setRoomReadMarkersHttpRequest).toHaveBeenCalledWith(
"room_id",
"read_marker_event_id",
"read_event_id",
"read_private_event_id",
);
expect(room.addLocalEchoReceipt).toHaveBeenCalledTimes(2);
expect(room.addLocalEchoReceipt).toHaveBeenNthCalledWith(
1,
client.credentials.userId,
rrEvent,
ReceiptType.Read,
);
expect(room.addLocalEchoReceipt).toHaveBeenNthCalledWith(
2,
client.credentials.userId,
rpEvent,
ReceiptType.ReadPrivate,
);
});
});
describe("beacons", () => {
const roomId = '!room:server.org';
const content = makeBeaconInfoContent(100, true);
@@ -999,10 +1144,10 @@ describe("MatrixClient", function() {
});
it("creates new beacon info", async () => {
await client.unstable_createLiveBeacon(roomId, content, '123');
await client.unstable_createLiveBeacon(roomId, content);
// event type combined
const expectedEventType = `${M_BEACON_INFO.name}.${userId}.123`;
const expectedEventType = M_BEACON_INFO.name;
const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0];
expect(callback).toBeFalsy();
expect(method).toBe('PUT');
@@ -1015,17 +1160,256 @@ describe("MatrixClient", function() {
});
it("updates beacon info with specific event type", async () => {
const eventType = `${M_BEACON_INFO.name}.${userId}.456`;
await client.unstable_setLiveBeacon(roomId, eventType, content);
await client.unstable_setLiveBeacon(roomId, content);
// event type combined
const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0];
expect(path).toEqual(
`/rooms/${encodeURIComponent(roomId)}/state/` +
`${encodeURIComponent(eventType)}/${encodeURIComponent(userId)}`,
`${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`,
);
expect(requestContent).toEqual(content);
});
describe('processBeaconEvents()', () => {
it('does nothing when events is falsy', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
client.processBeaconEvents(room, undefined);
expect(roomStateProcessSpy).not.toHaveBeenCalled();
});
it('does nothing when events is of length 0', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
client.processBeaconEvents(room, []);
expect(roomStateProcessSpy).not.toHaveBeenCalled();
});
it('calls room states processBeaconEvents with events', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
const messageEvent = testUtils.mkMessage({ room: roomId, user: userId, event: true });
const beaconEvent = makeBeaconEvent(userId);
client.processBeaconEvents(room, [messageEvent, beaconEvent]);
expect(roomStateProcessSpy).toHaveBeenCalledWith([messageEvent, beaconEvent], client);
});
});
});
describe("setRoomTopic", () => {
const roomId = "!foofoofoofoofoofoo:matrix.org";
const createSendStateEventMock = (topic: string, htmlTopic?: string) => {
return jest.fn()
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
expect(roomId).toEqual(roomId);
expect(eventType).toEqual(EventType.RoomTopic);
expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic));
expect(stateKey).toBeUndefined();
return Promise.resolve();
});
};
it("is called with plain text topic and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza");
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
it("is called with plain text topic and callback and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza", () => {});
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
it("is called with plain text and HTML topic and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza", "<b>pizza</b>");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza", "<b>pizza</b>");
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
});
describe("setPassword", () => {
const auth = { session: 'abcdef', type: 'foo' };
const newPassword = 'newpassword';
const callback = () => {};
const passwordTest = (expectedRequestContent: any, expectedCallback?: Function) => {
const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0];
if (expectedCallback) {
expect(callback).toBe(expectedCallback);
} else {
expect(callback).toBeFalsy();
}
expect(method).toBe('POST');
expect(path).toEqual('/account/password');
expect(queryParams).toBeFalsy();
expect(requestContent).toEqual(expectedRequestContent);
};
beforeEach(() => {
client.http.authedRequest.mockClear().mockResolvedValue({});
});
it("no logout_devices specified", async () => {
await client.setPassword(auth, newPassword);
passwordTest({ auth, new_password: newPassword });
});
it("no logout_devices specified + callback", async () => {
await client.setPassword(auth, newPassword, callback);
passwordTest({ auth, new_password: newPassword }, callback);
});
it("overload logoutDevices=true", async () => {
await client.setPassword(auth, newPassword, true);
passwordTest({ auth, new_password: newPassword, logout_devices: true });
});
it("overload logoutDevices=true + callback", async () => {
await client.setPassword(auth, newPassword, true, callback);
passwordTest({ auth, new_password: newPassword, logout_devices: true }, callback);
});
it("overload logoutDevices=false", async () => {
await client.setPassword(auth, newPassword, false);
passwordTest({ auth, new_password: newPassword, logout_devices: false });
});
it("overload logoutDevices=false + callback", async () => {
await client.setPassword(auth, newPassword, false, callback);
passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback);
});
});
describe("getLocalAliases", () => {
it("should call the right endpoint", async () => {
const response = {
aliases: ["#woop:example.org", "#another:example.org"],
};
client.http.authedRequest.mockClear().mockResolvedValue(response);
const roomId = "!whatever:example.org";
const result = await client.getLocalAliases(roomId);
// Current version of the endpoint we support is v3
const [callback, method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0];
expect(callback).toBeFalsy();
expect(data).toBeFalsy();
expect(method).toBe('GET');
expect(path).toEqual(`/rooms/${encodeURIComponent(roomId)}/aliases`);
expect(opts).toMatchObject({ prefix: "/_matrix/client/v3" });
expect(queryParams).toBeFalsy();
expect(result!.aliases).toEqual(response.aliases);
});
});
describe("pollingTurnServers", () => {
afterEach(() => {
mocked(supportsMatrixCall).mockReset();
});
it("is false if the client isn't started", () => {
expect(client.clientRunning).toBe(false);
expect(client.pollingTurnServers).toBe(false);
});
it("is false if VoIP is not supported", async () => {
mocked(supportsMatrixCall).mockReturnValue(false);
makeClient(); // create the client a second time so it picks up the supportsMatrixCall mock
await client.startClient();
expect(client.pollingTurnServers).toBe(false);
});
it("is true if VoIP is supported", async () => {
mocked(supportsMatrixCall).mockReturnValue(true);
makeClient(); // create the client a second time so it picks up the supportsMatrixCall mock
await client.startClient();
expect(client.pollingTurnServers).toBe(true);
});
});
describe("checkTurnServers", () => {
beforeAll(() => {
mocked(supportsMatrixCall).mockReturnValue(true);
});
beforeEach(() => {
makeClient(); // create the client a second time so it picks up the supportsMatrixCall mock
});
afterAll(() => {
mocked(supportsMatrixCall).mockReset();
});
it("emits an event when new TURN creds are found", async () => {
const turnServer = {
uris: [
"turn:turn.example.com:3478?transport=udp",
"turn:10.20.30.40:3478?transport=tcp",
"turns:10.20.30.40:443?transport=tcp",
],
username: "1443779631:@user:example.com",
password: "JlKfBy1QwLrO20385QyAtEyIv0=",
};
jest.spyOn(client, "turnServer").mockResolvedValue(turnServer);
const events: any[][] = [];
const onTurnServers = (...args) => events.push(args);
client.on(ClientEvent.TurnServers, onTurnServers);
expect(await client.checkTurnServers()).toBe(true);
client.off(ClientEvent.TurnServers, onTurnServers);
expect(events).toEqual([[[{
urls: turnServer.uris,
username: turnServer.username,
credential: turnServer.password,
}]]]);
});
it("emits an event when an error occurs", async () => {
const error = new Error(":(");
jest.spyOn(client, "turnServer").mockRejectedValue(error);
const events: any[][] = [];
const onTurnServersError = (...args) => events.push(args);
client.on(ClientEvent.TurnServersError, onTurnServersError);
expect(await client.checkTurnServers()).toBe(false);
client.off(ClientEvent.TurnServersError, onTurnServersError);
expect(events).toEqual([[error, false]]); // non-fatal
});
it("considers 403 errors fatal", async () => {
const error = { httpStatus: 403 };
jest.spyOn(client, "turnServer").mockRejectedValue(error);
const events: any[][] = [];
const onTurnServersError = (...args) => events.push(args);
client.on(ClientEvent.TurnServersError, onTurnServersError);
expect(await client.checkTurnServers()).toBe(false);
client.off(ClientEvent.TurnServersError, onTurnServersError);
expect(events).toEqual([[error, true]]); // fatal
});
});
describe("encryptAndSendToDevices", () => {
it("throws an error if crypto is unavailable", () => {
client.crypto = undefined;
expect(() => client.encryptAndSendToDevices([], {})).toThrow();
});
it("is an alias for the crypto method", async () => {
client.crypto = testUtils.mock(Crypto, "Crypto");
const deviceInfos = [];
const payload = {};
await client.encryptAndSendToDevices(deviceInfos, payload);
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
});
});
});
+297 -48
View File
@@ -14,15 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import { MatrixEvent } from "../../../src";
import {
isTimestampInDuration,
isBeaconInfoEventType,
Beacon,
BeaconEvent,
} from "../../../src/models/beacon";
import { makeBeaconInfoEvent } from "../../test-utils/beacon";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon";
jest.useFakeTimers();
@@ -57,27 +55,9 @@ describe('Beacon', () => {
});
});
describe('isBeaconInfoEventType', () => {
it.each([
EventType.CallAnswer,
`prefix.${M_BEACON_INFO.name}`,
`prefix.${M_BEACON_INFO.altName}`,
])('returns false for %s', (type) => {
expect(isBeaconInfoEventType(type)).toBe(false);
});
it.each([
M_BEACON_INFO.name,
M_BEACON_INFO.altName,
`${M_BEACON_INFO.name}.@test:server.org.12345`,
`${M_BEACON_INFO.altName}.@test:server.org.12345`,
])('returns true for %s', (type) => {
expect(isBeaconInfoEventType(type)).toBe(true);
});
});
describe('Beacon', () => {
const userId = '@user:server.org';
const userId2 = '@user2:server.org';
const roomId = '$room:server.org';
// 14.03.2022 16:15
const now = 1647270879403;
@@ -86,38 +66,50 @@ describe('Beacon', () => {
// beacon_info events
// created 'an hour ago'
// without timeout of 3 hours
let liveBeaconEvent;
let notLiveBeaconEvent;
let liveBeaconEvent: MatrixEvent;
let notLiveBeaconEvent: MatrixEvent;
let user2BeaconEvent: MatrixEvent;
const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock
jest.spyOn(global.Date, 'now').mockReturnValue(now + ms);
jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms);
// then advance time for the interval by the same amount
jest.advanceTimersByTime(ms);
};
beforeEach(() => {
// go back in time to create the beacon
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS);
liveBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
timestamp: now - HOUR_MS,
},
'$live123',
'$live123',
);
notLiveBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{ timeout: HOUR_MS * 3, isLive: false },
'$dead123',
{
timeout: HOUR_MS * 3,
isLive: false,
timestamp: now - HOUR_MS,
},
'$dead123',
);
user2BeaconEvent = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
timestamp: now - HOUR_MS,
},
'$user2live123',
);
// back to now
// back to 'now'
jest.spyOn(global.Date, 'now').mockReturnValue(now);
});
@@ -133,7 +125,7 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true);
expect(beacon.beaconInfoOwner).toEqual(userId);
expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType());
expect(beacon.identifier).toEqual(liveBeaconEvent.getType());
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
expect(beacon.beaconInfo).toBeTruthy();
});
@@ -144,17 +136,81 @@ describe('Beacon', () => {
});
it('returns false when beacon is expired', () => {
// time travel to beacon creation + 3 hours
jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS);
const beacon = new Beacon(liveBeaconEvent);
const expiredBeaconEvent = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now - HOUR_MS * 2,
},
'$user2live123',
);
const beacon = new Beacon(expiredBeaconEvent);
expect(beacon.isLive).toEqual(false);
});
it('returns false when beacon timestamp is in future', () => {
// time travel to before beacon events timestamp
// event was created now - 1 hour
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS);
const beacon = new Beacon(liveBeaconEvent);
it('returns false when beacon timestamp is in future by an hour', () => {
const beaconStartsInHour = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now + HOUR_MS,
},
'$user2live123',
);
const beacon = new Beacon(beaconStartsInHour);
expect(beacon.isLive).toEqual(false);
});
it('returns true when beacon timestamp is one minute in the future', () => {
const beaconStartsInOneMin = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now + 60000,
},
'$user2live123',
);
const beacon = new Beacon(beaconStartsInOneMin);
expect(beacon.isLive).toEqual(true);
});
it('returns true when beacon timestamp is one minute before expiry', () => {
// this test case is to check the start time leniency doesn't affect
// strict expiry time checks
const expiresInOneMin = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now - HOUR_MS + 60000,
},
'$user2live123',
);
const beacon = new Beacon(expiresInOneMin);
expect(beacon.isLive).toEqual(true);
});
it('returns false when beacon timestamp is one minute after expiry', () => {
// this test case is to check the start time leniency doesn't affect
// strict expiry time checks
const expiredOneMinAgo = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now - HOUR_MS - 60000,
},
'$user2live123',
);
const beacon = new Beacon(expiredOneMinAgo);
expect(beacon.isLive).toEqual(false);
});
@@ -171,8 +227,27 @@ describe('Beacon', () => {
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
expect(() => beacon.update(notLiveBeaconEvent)).toThrow();
expect(beacon.isLive).toEqual(true);
expect(() => beacon.update(user2BeaconEvent)).toThrow();
// didnt update
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
});
it('does not update with an older event', () => {
const beacon = new Beacon(liveBeaconEvent);
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
const oldUpdateEvent = makeBeaconInfoEvent(
userId,
roomId,
);
// less than the original event
oldUpdateEvent.event.origin_server_ts = liveBeaconEvent.event.origin_server_ts - 1000;
beacon.update(oldUpdateEvent);
// didnt update
expect(emitSpy).not.toHaveBeenCalled();
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
});
it('updates event', () => {
@@ -182,7 +257,7 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true);
const updatedBeaconEvent = makeBeaconInfoEvent(
userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123', '$live123');
userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123');
beacon.update(updatedBeaconEvent);
expect(beacon.isLive).toEqual(false);
@@ -200,7 +275,6 @@ describe('Beacon', () => {
roomId,
{ timeout: HOUR_MS * 3, isLive: false },
beacon.beaconInfoId,
'$live123',
);
beacon.update(updatedBeaconEvent);
@@ -219,13 +293,47 @@ describe('Beacon', () => {
beacon.monitorLiveness();
// @ts-ignore
expect(beacon.livenessWatchInterval).toBeFalsy();
expect(beacon.livenessWatchTimeout).toBeFalsy();
advanceDateAndTime(HOUR_MS * 2 + 1);
// no emit
expect(emitSpy).not.toHaveBeenCalled();
});
it('checks liveness of beacon at expected start time', () => {
const futureBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
// start timestamp hour in future
timestamp: now + HOUR_MS,
},
'$live123',
);
const beacon = new Beacon(futureBeaconEvent);
expect(beacon.isLive).toBeFalsy();
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.monitorLiveness();
// advance to the start timestamp of the beacon
advanceDateAndTime(HOUR_MS + 1);
// beacon is in live period now
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, true, beacon);
// check the expiry monitor is still setup ok
// advance to the expiry
advanceDateAndTime(HOUR_MS * 3 + 100);
expect(emitSpy).toHaveBeenCalledTimes(2);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon);
});
it('checks liveness of beacon at expected expiry time', () => {
// live beacon was created an hour ago
// and has a 3hr duration
@@ -248,12 +356,12 @@ describe('Beacon', () => {
beacon.monitorLiveness();
// @ts-ignore
const oldMonitor = beacon.livenessWatchInterval;
const oldMonitor = beacon.livenessWatchTimeout;
beacon.monitorLiveness();
// @ts-ignore
expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor);
expect(beacon.livenessWatchTimeout).not.toEqual(oldMonitor);
});
it('destroy kills liveness monitor and emits', () => {
@@ -277,5 +385,146 @@ describe('Beacon', () => {
expect(emitSpy).toHaveBeenCalledTimes(1);
});
});
describe('addLocations', () => {
it('ignores locations when beacon is not live', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: false }));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 1 }),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('ignores locations outside the beacon live duration', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 60000 live period
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 100000 }),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
describe('when beacon is live with a start timestamp is in the future', () => {
it('ignores locations before the beacon start timestamp', () => {
const startTimestamp = now + 60000;
const beacon = new Beacon(makeBeaconInfoEvent(
userId,
roomId,
{ isLive: true, timeout: 60000, timestamp: startTimestamp },
));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 60000 live period
makeBeaconEvent(
userId,
{
beaconInfoId: beacon.beaconInfoId,
// now < location timestamp < beacon timestamp
timestamp: now + 10,
},
),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('sets latest location when location timestamp is after startTimestamp', () => {
const startTimestamp = now + 60000;
const beacon = new Beacon(makeBeaconInfoEvent(
userId,
roomId,
{ isLive: true, timeout: 600000, timestamp: startTimestamp },
));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 600000 live period
makeBeaconEvent(
userId,
{
beaconInfoId: beacon.beaconInfoId,
// now < beacon timestamp < location timestamp
timestamp: startTimestamp + 10,
},
),
]);
expect(beacon.latestLocationState).toBeTruthy();
expect(emitSpy).toHaveBeenCalled();
});
});
it('sets latest location state to most recent location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
const locations = [
// older
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
),
// newer
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
),
// not valid
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:baz', timestamp: now - 5 },
),
];
beacon.addLocations(locations);
const expectedLatestLocation = {
description: undefined,
timestamp: now + 10000,
uri: 'geo:bar',
};
// the newest valid location
expect(beacon.latestLocationState).toEqual(expectedLatestLocation);
expect(beacon.latestLocationEvent).toEqual(locations[1]);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation);
});
it('ignores locations that are less recent that the current latest location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const olderLocation = makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
);
const newerLocation = makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
);
beacon.addLocations([newerLocation]);
// latest location set to newerLocation
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar',
}));
expect(beacon.latestLocationEvent).toEqual(newerLocation);
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
// add older location
beacon.addLocations([olderLocation]);
// no change
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar',
}));
// no emit
expect(emitSpy).not.toHaveBeenCalled();
});
});
});
});
+27
View File
@@ -57,4 +57,31 @@ describe('MatrixEvent', () => {
expect(a.toSnapshot().isEquivalentTo(a)).toBe(true);
expect(a.toSnapshot().isEquivalentTo(b)).toBe(false);
});
it("should prune clearEvent when being redacted", () => {
const ev = new MatrixEvent({
type: "m.room.message",
content: {
body: "Test",
},
event_id: "$event1:server",
});
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBe("Test");
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBe("xyz");
const redaction = new MatrixEvent({
type: "m.room.redaction",
redacts: ev.getId(),
});
ev.makeRedacted(redaction);
expect(ev.getContent().body).toBeUndefined();
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBeUndefined();
});
});
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright 2022 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 { Thread } from "../../../src/models/thread";
describe('Thread', () => {
describe("constructor", () => {
it("should explode for element-web#22141 logging", () => {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
expect(() => {
new Thread("$event", undefined, {} as any); // deliberate cast to test error case
}).toThrow("element-web#22141: A thread requires a room in order to function");
});
});
});
@@ -1,14 +1,15 @@
import * as utils from "../test-utils/test-utils";
import { PushProcessor } from "../../src/pushprocessor";
import { EventType, MatrixClient, MatrixEvent } from "../../src";
describe('NotificationService', function() {
const testUserId = "@ali:matrix.org";
const testDisplayName = "Alice M";
const testRoomId = "!fl1bb13:localhost";
let testEvent;
let testEvent: MatrixEvent;
let pushProcessor;
let pushProcessor: PushProcessor;
// These would be better if individual rules were configured in the tests themselves.
const matrixClient = {
@@ -195,7 +196,7 @@ describe('NotificationService', function() {
],
},
},
};
} as unknown as MatrixClient;
beforeEach(function() {
testEvent = utils.mkEvent({
@@ -208,6 +209,7 @@ describe('NotificationService', function() {
msgtype: "m.text",
},
});
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules);
pushProcessor = new PushProcessor(matrixClient);
});
@@ -295,6 +297,22 @@ describe('NotificationService', function() {
expect(actions.tweaks.highlight).toEqual(false);
});
it('should not bing on room server ACL changes', function() {
testEvent = utils.mkEvent({
type: EventType.RoomServerAcl,
room: testRoomId,
user: "@alfred:localhost",
skey: "",
event: true,
content: {},
});
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toBeFalsy();
expect(actions.tweaks.sound).toBeFalsy();
expect(actions.notify).toBeFalsy();
});
// invalid
it('should gracefully handle bad input.', function() {
+349
View File
@@ -0,0 +1,349 @@
/*
Copyright 2022 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 MockHttpBackend from 'matrix-mock-request';
import { indexedDB as fakeIndexedDB } from 'fake-indexeddb';
import { IHttpOpts, IndexedDBStore, MatrixEvent, MemoryStore, Room } from "../../src";
import { MatrixClient } from "../../src/client";
import { ToDeviceBatch } from '../../src/models/ToDeviceMessage';
import { logger } from '../../src/logger';
import { IStore } from '../../src/store';
const FAKE_USER = "@alice:example.org";
const FAKE_DEVICE_ID = "AAAAAAAA";
const FAKE_PAYLOAD = {
"foo": 42,
};
const EXPECTED_BODY = {
messages: {
[FAKE_USER]: {
[FAKE_DEVICE_ID]: FAKE_PAYLOAD,
},
},
};
const FAKE_MSG = {
userId: FAKE_USER,
deviceId: FAKE_DEVICE_ID,
payload: FAKE_PAYLOAD,
};
enum StoreType {
Memory = 'Memory',
IndexedDB = 'IndexedDB',
}
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
// other async methods which break the event loop, letting scheduled promise
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
// it manually (this is what sinon does under the hood). We do both in a loop
// until the thing we expect happens: hopefully this is the least flakey way
// and avoids assuming anything about the app's behaviour.
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
async function flushAndRunTimersUntil(cond: () => boolean) {
while (!cond()) {
await flushPromises();
if (cond()) break;
jest.advanceTimersToNextTimer();
}
}
describe.each([
[StoreType.Memory], [StoreType.IndexedDB],
])("queueToDevice (%s store)", function(storeType) {
let httpBackend: MockHttpBackend;
let client: MatrixClient;
beforeEach(async function() {
httpBackend = new MockHttpBackend();
let store: IStore;
if (storeType === StoreType.IndexedDB) {
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
await idbStore.startup();
store = idbStore;
} else {
store = new MemoryStore();
}
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: httpBackend.requestFn as IHttpOpts["request"],
store,
});
});
afterEach(function() {
jest.useRealTimers();
client.stopClient();
});
it("sends a to-device message", async function() {
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
}).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await httpBackend.flushAllExpected();
// let the code handle the response to the request so we don't get
// log output after the test has finished (apparently stopping the
// client in aftereach is not sufficient.)
await flushPromises();
});
it("retries on error", async function() {
jest.useFakeTimers();
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
}).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
// flush, as per comment in first test
await flushPromises();
});
it("stops retrying on 4xx errors", async function() {
jest.useFakeTimers();
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(400);
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
// Asserting that another request is never made is obviously
// a bit tricky - we just flush the queue what should hopefully
// be plenty of times and assert that nothing comes through.
let tries = 0;
await flushAndRunTimersUntil(() => ++tries === 10);
expect(httpBackend.requests.length).toEqual(0);
});
it("honours ratelimiting", async function() {
jest.useFakeTimers();
// pick something obscure enough it's unlikley to clash with a
// retry delay the algorithm uses anyway
const retryDelay = 279 * 1000;
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(429, {
errcode: "M_LIMIT_EXCEEDED",
retry_after_ms: retryDelay,
});
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(null, 1)).toEqual(1);
await flushPromises();
logger.info("Advancing clock to just before expected retry time...");
jest.advanceTimersByTime(retryDelay - 1000);
await flushPromises();
expect(httpBackend.requests.length).toEqual(0);
logger.info("Advancing clock past expected retry time...");
jest.advanceTimersByTime(2000);
await flushPromises();
expect(httpBackend.flushSync(null, 1)).toEqual(1);
});
it("retries on retryImmediately()", async function() {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(null, 1, 20)]);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
await flushPromises();
client.retryImmediately();
// longer timeout here to try & avoid flakiness
expect(await httpBackend.flush(null, 1, 3000)).toEqual(1);
});
it("retries on when client is started", async function() {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
});
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
await flushPromises();
client.stopClient();
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
});
it("retries when a message is retried", async function() {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.0.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(null, 1, 20)]);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(500);
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [
FAKE_MSG,
],
});
expect(await httpBackend.flush(null, 1, 1)).toEqual(1);
await flushPromises();
const dummyEvent = new MatrixEvent({
event_id: "!fake:example.org",
});
const mockRoom = {
updatePendingEvent: jest.fn(),
} as unknown as Room;
client.resendEvent(dummyEvent, mockRoom);
expect(await httpBackend.flush(null, 1, 20)).toEqual(1);
});
it("splits many messages into multiple HTTP requests", async function() {
const batch: ToDeviceBatch = {
eventType: "org.example.foo",
batch: [],
};
for (let i = 0; i <= 20; ++i) {
batch.batch.push({
userId: `@user${i}:example.org`,
deviceId: FAKE_DEVICE_ID,
payload: FAKE_PAYLOAD,
});
}
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(20);
}).respond(200, {});
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(1);
}).respond(200, {});
await client.queueToDevice(batch);
await httpBackend.flushAllExpected();
// flush, as per comment in first test
await flushPromises();
});
});
@@ -1,7 +1,7 @@
import * as callbacks from "../../src/realtime-callbacks";
let wallTime = 1234567890;
jest.useFakeTimers();
jest.useFakeTimers().setSystemTime(wallTime);
describe("realtime-callbacks", function() {
function tick(millis) {
@@ -9,14 +9,6 @@ describe("realtime-callbacks", function() {
jest.advanceTimersByTime(millis);
}
beforeEach(function() {
callbacks.setNow(() => wallTime);
});
afterEach(function() {
callbacks.setNow();
});
describe("setTimeout", function() {
it("should call the callback after the timeout", function() {
const callback = jest.fn();
@@ -29,7 +21,7 @@ describe("realtime-callbacks", function() {
it("should default to a zero timeout", function() {
const callback = jest.fn();
callbacks.setTimeout(callback);
callbacks.setTimeout(callback, 0);
expect(callback).not.toHaveBeenCalled();
tick(0);
@@ -46,11 +38,11 @@ describe("realtime-callbacks", function() {
it("should set 'this' to the global object", function() {
let passed = false;
const callback = function() {
expect(this).toBe(global); // eslint-disable-line @babel/no-invalid-this
expect(this.console).toBeTruthy(); // eslint-disable-line @babel/no-invalid-this
expect(this).toBe(global); // eslint-disable-line @typescript-eslint/no-invalid-this
expect(this.console).toBeTruthy(); // eslint-disable-line @typescript-eslint/no-invalid-this
passed = true;
};
callbacks.setTimeout(callback);
callbacks.setTimeout(callback, 0);
tick(0);
expect(passed).toBe(true);
});
@@ -100,7 +92,7 @@ describe("realtime-callbacks", function() {
expect(callback2).not.toHaveBeenCalled();
});
callbacks.setTimeout(callback1);
callbacks.setTimeout(callback1, 0);
callbacks.setTimeout(callback2, -100);
expect(callback1).not.toHaveBeenCalled();
@@ -117,14 +109,14 @@ describe("realtime-callbacks", function() {
expect(callback2).not.toHaveBeenCalled();
});
callbacks.setTimeout(callback1);
callbacks.setTimeout(callback1, 1);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
tick(0);
tick(1);
expect(callback1).toHaveBeenCalled();
// the fake timer won't actually run callbacks registered during
// one tick until the next tick.
tick(1);
tick(2);
expect(callback2).toHaveBeenCalled();
});
@@ -147,9 +139,9 @@ describe("realtime-callbacks", function() {
describe("cancelTimeout", function() {
it("should cancel a pending timeout", function() {
const callback = jest.fn();
const k = callbacks.setTimeout(callback);
const k = callbacks.setTimeout(callback, 10);
callbacks.clearTimeout(k);
tick(0);
tick(11);
expect(callback).not.toHaveBeenCalled();
});
+59 -10
View File
@@ -96,19 +96,14 @@ describe("Relations", function() {
},
});
// Stub the room
const room = new Room("room123", null, null);
// Add the target event first, then the relation event
{
const room = new Room("room123", null, null);
const relationsCreated = new Promise(resolve => {
targetEvent.once(MatrixEventEvent.RelationsCreated, resolve);
});
const timelineSet = new EventTimelineSet(room, {
unstableClientRelationAggregation: true,
});
const timelineSet = new EventTimelineSet(room);
timelineSet.addLiveEvent(targetEvent);
timelineSet.addLiveEvent(relationEvent);
@@ -117,17 +112,71 @@ describe("Relations", function() {
// Add the relation event first, then the target event
{
const room = new Room("room123", null, null);
const relationsCreated = new Promise(resolve => {
targetEvent.once(MatrixEventEvent.RelationsCreated, resolve);
});
const timelineSet = new EventTimelineSet(room, {
unstableClientRelationAggregation: true,
});
const timelineSet = new EventTimelineSet(room);
timelineSet.addLiveEvent(relationEvent);
timelineSet.addLiveEvent(targetEvent);
await relationsCreated;
}
});
it("should re-use Relations between all timeline sets in a room", async () => {
const room = new Room("room123", null, null);
const timelineSet1 = new EventTimelineSet(room);
const timelineSet2 = new EventTimelineSet(room);
expect(room.relations).toBe(timelineSet1.relations);
expect(room.relations).toBe(timelineSet2.relations);
});
it("should ignore m.replace for state events", async () => {
const userId = "@bob:example.com";
const room = new Room("room123", null, userId);
const relations = new Relations("m.replace", "m.room.topic", room);
// Create an instance of a state event with rel_type m.replace
const originalTopic = new MatrixEvent({
"sender": userId,
"type": "m.room.topic",
"event_id": "$orig",
"room_id": room.roomId,
"content": {
"topic": "orig",
},
"state_key": "",
});
const badlyEditedTopic = new MatrixEvent({
"sender": userId,
"type": "m.room.topic",
"event_id": "$orig",
"room_id": room.roomId,
"content": {
"topic": "topic",
"m.new_content": {
"topic": "edit",
},
"m.relates_to": {
"event_id": "$orig",
"rel_type": "m.replace",
},
},
"state_key": "",
});
await relations.setTargetEvent(originalTopic);
expect(originalTopic.replacingEvent()).toBe(null);
expect(originalTopic.getContent().topic).toBe("orig");
expect(badlyEditedTopic.isRelation()).toBe(false);
expect(badlyEditedTopic.isRelation("m.replace")).toBe(false);
await relations.addEvent(badlyEditedTopic);
expect(originalTopic.replacingEvent()).toBe(null);
expect(originalTopic.getContent().topic).toBe("orig");
expect(badlyEditedTopic.replacingEvent()).toBe(null);
expect(badlyEditedTopic.getContent().topic).toBe("topic");
});
});
+285 -17
View File
@@ -1,8 +1,14 @@
import * as utils from "../test-utils/test-utils";
import { makeBeaconInfoEvent } from "../test-utils/beacon";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent } from "../../src/models/beacon";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import {
MatrixEvent,
MatrixEventEvent,
} from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
describe("RoomState", function() {
const roomId = "!foo:bar";
@@ -252,6 +258,29 @@ describe("RoomState", function() {
);
});
it("should emit `RoomStateEvent.Marker` for each marker event", function() {
const events = [
utils.mkEvent({
event: true,
type: UNSTABLE_MSC2716_MARKER.name,
room: roomId,
user: userA,
skey: "",
content: {
"m.insertion_id": "$abc",
},
}),
];
let emitCount = 0;
state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) {
expect(markerEvent).toEqual(events[emitCount]);
expect(markerFoundOptions).toEqual({ timelineWasEmpty: true });
emitCount += 1;
});
state.setStateEvents(events, { timelineWasEmpty: true });
expect(emitCount).toEqual(1);
});
describe('beacon events', () => {
it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
@@ -260,7 +289,7 @@ describe("RoomState", function() {
state.setStateEvents([beaconEvent]);
expect(state.beacons.size).toEqual(1);
const beaconInstance = state.beacons.get(beaconEvent.getType());
const beaconInstance = state.beacons.get(`${roomId}_${userA}`);
expect(beaconInstance).toBeTruthy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance);
});
@@ -275,49 +304,49 @@ describe("RoomState", function() {
// no beacon added
expect(state.beacons.size).toEqual(0);
expect(state.beacons.get(redactedBeaconEvent.getType)).toBeFalsy();
expect(state.beacons.get(getBeaconInfoIdentifier(redactedBeaconEvent))).toBeFalsy();
// no new beacon emit
expect(filterEmitCallsByEventType(BeaconEvent.New, emitSpy).length).toBeFalsy();
});
it('updates existing beacon info events in state', () => {
const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId);
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId, beaconId);
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(beaconEvent.getType());
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([updatedBeaconEvent]);
// same Beacon
expect(state.beacons.get(beaconEvent.getType())).toBe(beaconInstance);
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance);
// updated liveness
expect(state.beacons.get(beaconEvent.getType()).isLive).toEqual(false);
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent)).isLive).toEqual(false);
});
it('destroys and removes redacted beacon events', () => {
const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId);
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId);
const redactionEvent = { event: { type: 'm.room.redaction' } };
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const redactionEvent = { event: { type: 'm.room.redaction', redacts: beaconEvent.getId() } };
redactedBeaconEvent.makeRedacted(redactionEvent);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(beaconEvent.getType());
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
const destroySpy = jest.spyOn(beaconInstance, 'destroy');
expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([redactedBeaconEvent]);
expect(destroySpy).toHaveBeenCalled();
expect(state.beacons.get(beaconEvent.getType())).toBe(undefined);
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(undefined);
});
it('updates live beacon ids once after setting state events', () => {
const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1', '$beacon1');
const deadBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, '$beacon2', '$beacon2');
const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1');
const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, '$beacon2');
const emitSpy = jest.spyOn(state, 'emit');
@@ -334,7 +363,7 @@ describe("RoomState", function() {
state.setStateEvents([updatedLiveBeaconEvent]);
expect(state.hasLiveBeacons).toBe(false);
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(2);
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(3);
expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false);
});
});
@@ -712,4 +741,243 @@ describe("RoomState", function() {
expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false);
});
});
describe('processBeaconEvents', () => {
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1');
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2');
const mockClient = { decryptEventIfNeeded: jest.fn() };
beforeEach(() => {
mockClient.decryptEventIfNeeded.mockClear();
});
it('does nothing when state has no beacons', () => {
const emitSpy = jest.spyOn(state, 'emit');
state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: '$beacon1' })], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('does nothing when there are no events', () => {
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
describe('without encryption', () => {
it('discards events for beacons that are not in state', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: 'some-other-beacon',
});
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessage,
content: {
['m.relates_to']: {
event_id: 'whatever',
},
},
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
});
it('discards events that are not beacon type', () => {
// related to beacon1
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessage,
content: {
['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: beacon1.getId(),
},
},
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([otherRelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
});
it('adds locations to beacons', () => {
const location1 = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
});
const location2 = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 2,
});
const location3 = makeBeaconEvent(userB, {
beaconInfoId: 'some-other-beacon',
});
state.setStateEvents([beacon1, beacon2], mockClient);
expect(state.beacons.size).toEqual(2);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations');
state.processBeaconEvents([location1, location2, location3], mockClient);
expect(addLocationsSpy).toHaveBeenCalledTimes(2);
// only called with locations for beacon1
expect(addLocationsSpy).toHaveBeenCalledWith([location1]);
expect(addLocationsSpy).toHaveBeenCalledWith([location2]);
});
});
describe('with encryption', () => {
const beacon1RelationContent = { ['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: beacon1.getId(),
} };
const relatedEncryptedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true);
it('discards events without relations', () => {
const unrelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([unrelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('discards events for beacons that are not in state', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: 'some-other-beacon',
});
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: {
['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: 'whatever',
},
},
});
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(addLocationsSpy).not.toHaveBeenCalled();
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('decrypts related events if needed', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: beacon1.getId(),
});
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
});
it('listens for decryption on events that are being decrypted', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once');
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it('listens for decryption on events that have decryption failure', () => {
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once');
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it('discard events that are not m.beacon type after decryption', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// this event is a message after decryption
decryptingRelatedEvent.type = EventType.RoomMessage;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted);
expect(addLocationsSpy).not.toHaveBeenCalled();
});
it('adds locations to beacons after decryption', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
const locationEvent = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted);
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
});
});
});
});
+465 -144
View File
File diff suppressed because it is too large Load Diff
@@ -45,8 +45,8 @@ describe("MatrixScheduler", function() {
queueFn = function() {
return "one_big_queue";
};
const deferA = defer();
const deferB = defer();
const deferA = defer<Record<string, boolean>>();
const deferB = defer<Record<string, boolean>>();
let yieldedA = false;
scheduler.setProcessFunction(function(event) {
if (yieldedA) {
@@ -70,84 +70,84 @@ describe("MatrixScheduler", function() {
});
it("should invoke the retryFn on failure and wait the amount of time specified",
async function() {
const waitTimeMs = 1500;
const retryDefer = defer();
retryFn = function() {
retryDefer.resolve();
return waitTimeMs;
};
queueFn = function() {
return "yep";
};
async function() {
const waitTimeMs = 1500;
const retryDefer = defer();
retryFn = function() {
retryDefer.resolve();
return waitTimeMs;
};
queueFn = function() {
return "yep";
};
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferred.promise;
} else if (procCount === 2) {
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferred.promise;
} else if (procCount === 2) {
// don't care about this deferred
return new Promise();
}
expect(procCount).toBeLessThan(3);
return new Promise(() => {});
}
expect(procCount).toBeLessThan(3);
});
scheduler.queueEvent(eventA);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(1);
deferred.reject({});
await retryDefer.promise;
expect(procCount).toEqual(1);
jest.advanceTimersByTime(waitTimeMs);
await Promise.resolve();
expect(procCount).toEqual(2);
});
scheduler.queueEvent(eventA);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(1);
deferred.reject({});
await retryDefer.promise;
expect(procCount).toEqual(1);
jest.advanceTimersByTime(waitTimeMs);
await Promise.resolve();
expect(procCount).toEqual(2);
});
it("should give up if the retryFn on failure returns -1 and try the next event",
async function() {
async function() {
// Queue A & B.
// Reject A and return -1 on retry.
// Expect B to be tried next and the promise for A to be rejected.
retryFn = function() {
return -1;
};
queueFn = function() {
return "yep";
};
retryFn = function() {
return -1;
};
queueFn = function() {
return "yep";
};
const deferA = defer();
const deferB = defer();
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferA.promise;
} else if (procCount === 2) {
expect(ev).toEqual(eventB);
return deferB.promise;
}
expect(procCount).toBeLessThan(3);
});
const deferA = defer();
const deferB = defer();
let procCount = 0;
scheduler.setProcessFunction(function(ev) {
procCount += 1;
if (procCount === 1) {
expect(ev).toEqual(eventA);
return deferA.promise;
} else if (procCount === 2) {
expect(ev).toEqual(eventB);
return deferB.promise;
}
expect(procCount).toBeLessThan(3);
});
const globalA = scheduler.queueEvent(eventA);
scheduler.queueEvent(eventB);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(1);
deferA.reject({});
try {
await globalA;
} catch (err) {
const globalA = scheduler.queueEvent(eventA);
scheduler.queueEvent(eventB);
// as queueing doesn't start processing synchronously anymore (see commit bbdb5ac)
// wait just long enough before it does
await Promise.resolve();
expect(procCount).toEqual(2);
}
});
expect(procCount).toEqual(1);
deferA.reject({});
try {
await globalA;
} catch (err) {
await Promise.resolve();
expect(procCount).toEqual(2);
}
});
it("should treat each queue separately", function(done) {
// Queue messages A B C D.
@@ -175,7 +175,7 @@ describe("MatrixScheduler", function() {
const expectOrder = [
eventA.getId(), eventB.getId(), eventD.getId(),
];
const deferA = defer();
const deferA = defer<void>();
scheduler.setProcessFunction(function(event) {
const id = expectOrder.shift();
expect(id).toEqual(event.getId());
@@ -191,7 +191,7 @@ describe("MatrixScheduler", function() {
// wait a bit then resolve A and we should get D (not C) next.
setTimeout(function() {
deferA.resolve({});
deferA.resolve();
}, 1000);
jest.advanceTimersByTime(1000);
});
@@ -336,28 +336,29 @@ describe("MatrixScheduler", function() {
errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 5000,
}),
);
expect(res >= 500).toBe(true, "Didn't wait long enough.");
expect(res >= 500).toBe(true);
});
it("should give up after 5 attempts", function() {
const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 5, {},
eventA, 5, new MatrixError({}),
);
expect(res).toBe(-1, "Didn't give up.");
expect(res).toBe(-1);
});
it("should do exponential backoff", function() {
const error = new MatrixError({});
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 1, {},
eventA, 1, error,
)).toEqual(2000);
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 2, {},
eventA, 2, error,
)).toEqual(4000);
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 3, {},
eventA, 3, error,
)).toEqual(8000);
expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT(
eventA, 4, {},
eventA, 4, error,
)).toEqual(16000);
});
});
+114
View File
@@ -0,0 +1,114 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import 'fake-indexeddb/auto';
import 'jest-localstorage-mock';
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
describe("IndexedDBStore", () => {
afterEach(() => {
jest.clearAllMocks();
});
const roomId = "!room:id";
it("should degrade to MemoryStore on IDB errors", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
const member1: IStateEventWithRoomId = {
room_id: roomId,
event_id: "!ev1:id",
sender: "@user1:id",
state_key: "@user1:id",
type: "m.room.member",
origin_server_ts: 123,
content: {},
};
const member2: IStateEventWithRoomId = {
room_id: roomId,
event_id: "!ev2:id",
sender: "@user2:id",
state_key: "@user2:id",
type: "m.room.member",
origin_server_ts: 123,
content: {},
};
expect(await store.getOutOfBandMembers(roomId)).toBe(null);
await store.setOutOfBandMembers(roomId, [member1]);
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1);
// Simulate a broken IDB
(store.backend as LocalIndexedDBStoreBackend)["db"].transaction = (): IDBTransaction => {
const err = new Error("Failed to execute 'transaction' on 'IDBDatabase': " +
"The database connection is closing.");
err.name = "InvalidStateError";
throw err;
};
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1);
await Promise.all([
emitPromise(store["emitter"], "degraded"),
store.setOutOfBandMembers(roomId, [member1, member2]),
]);
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
});
it("should use MemoryStore methods for pending events if no localStorage", async () => {
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage: undefined,
});
const events = [{ type: "test" }];
await store.setPendingEvents(roomId, events);
expect(MemoryStore.prototype.setPendingEvents).toHaveBeenCalledWith(roomId, events);
await expect(store.getPendingEvents(roomId)).resolves.toEqual(events);
expect(MemoryStore.prototype.getPendingEvents).toHaveBeenCalledWith(roomId);
});
it("should persist pending events to localStorage if available", async () => {
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await expect(store.getPendingEvents(roomId)).resolves.toEqual([]);
const events = [{ type: "test" }];
await store.setPendingEvents(roomId, events);
expect(MemoryStore.prototype.setPendingEvents).not.toHaveBeenCalled();
await expect(store.getPendingEvents(roomId)).resolves.toEqual(events);
expect(MemoryStore.prototype.getPendingEvents).not.toHaveBeenCalled();
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBe(JSON.stringify(events));
await store.setPendingEvents(roomId, []);
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull();
});
});
@@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2022 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.
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ReceiptType } from "../../src/@types/read_receipts";
import { SyncAccumulator } from "../../src/sync-accumulator";
// The event body & unsigned object get frozen to assert that they don't get altered
@@ -294,10 +295,16 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event1:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 1 },
"@bob:localhost": { ts: 2 },
},
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
[ReceiptType.UnstableReadPrivate]: {
"@matthew:localhost": { ts: 5 },
},
"some.other.receipt.type": {
"@should_be_ignored:localhost": { key: "val" },
},
@@ -309,7 +316,7 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event2:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@bob:localhost": { ts: 2 }, // clobbers event1 receipt
"@charlie:localhost": { ts: 3 },
},
@@ -337,12 +344,18 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event1:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 1 },
},
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
[ReceiptType.UnstableReadPrivate]: {
"@matthew:localhost": { ts: 5 },
},
},
"$event2:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@bob:localhost": { ts: 2 },
"@charlie:localhost": { ts: 3 },
},
@@ -377,6 +390,10 @@ describe("SyncAccumulator", function() {
};
}
afterEach(() => {
jest.spyOn(global.Date, 'now').mockRestore();
});
it("should copy summary properties", function() {
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
@@ -406,25 +423,19 @@ describe("SyncAccumulator", function() {
const delta = 1000;
const startingTs = 1000;
const oldDateNow = Date.now;
try {
Date.now = jest.fn();
Date.now.mockReturnValue(startingTs);
jest.spyOn(global.Date, 'now').mockReturnValue(startingTs);
sa.accumulate(RES_WITH_AGE);
sa.accumulate(RES_WITH_AGE);
Date.now.mockReturnValue(startingTs + delta);
jest.spyOn(global.Date, 'now').mockReturnValue(startingTs + delta);
const output = sa.getJSON();
expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned.age).toEqual(
RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned.age + delta,
);
expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual(
Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]),
);
} finally {
Date.now = oldDateNow;
}
const output = sa.getJSON();
expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned.age).toEqual(
RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned.age + delta,
);
expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual(
Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]),
);
});
it("should mangle age without adding extra keys", () => {
-455
View File
@@ -1,455 +0,0 @@
import { EventTimeline } from "../../src/models/event-timeline";
import { TimelineIndex, TimelineWindow } from "../../src/timeline-window";
import * as utils from "../test-utils/test-utils";
const ROOM_ID = "roomId";
const USER_ID = "userId";
/*
* create a timeline with a bunch (default 3) events.
* baseIndex is 1 by default.
*/
function createTimeline(numEvents, baseIndex) {
if (numEvents === undefined) {
numEvents = 3;
}
if (baseIndex === undefined) {
baseIndex = 1;
}
// XXX: this is a horrid hack
const timelineSet = { room: { roomId: ROOM_ID } };
timelineSet.room.getUnfilteredTimelineSet = function() {
return timelineSet;
};
const timeline = new EventTimeline(timelineSet);
// add the events after the baseIndex first
addEventsToTimeline(timeline, numEvents - baseIndex, false);
// then add those before the baseIndex
addEventsToTimeline(timeline, baseIndex, true);
expect(timeline.getBaseIndex()).toEqual(baseIndex);
return timeline;
}
function addEventsToTimeline(timeline, numEvents, atStart) {
for (let i = 0; i < numEvents; i++) {
timeline.addEvent(
utils.mkMessage({
room: ROOM_ID, user: USER_ID,
event: true,
}), atStart,
);
}
}
/*
* create a pair of linked timelines
*/
function createLinkedTimelines() {
const tl1 = createTimeline();
const tl2 = createTimeline();
tl1.setNeighbouringTimeline(tl2, EventTimeline.FORWARDS);
tl2.setNeighbouringTimeline(tl1, EventTimeline.BACKWARDS);
return [tl1, tl2];
}
describe("TimelineIndex", function() {
describe("minIndex", function() {
it("should return the min index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.minIndex()).toEqual(-1);
});
});
describe("maxIndex", function() {
it("should return the max index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.maxIndex()).toEqual(2);
});
});
describe("advance", function() {
it("should advance up to the end of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(3);
expect(result).toEqual(2);
expect(timelineIndex.index).toEqual(2);
});
it("should retreat back to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(-2);
expect(result).toEqual(-1);
expect(timelineIndex.index).toEqual(-1);
});
it("should advance into the next timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the end of the first timeline
const timelineIndex = new TimelineIndex(tl1, 2);
const result = timelineIndex.advance(1);
expect(result).toEqual(1);
expect(timelineIndex.timeline).toBe(tl2);
// we expect the index to be the zero (ie, the same as the
// BaseIndex), because the BaseIndex points at the second event,
// and we've advanced past the first.
expect(timelineIndex.index).toEqual(0);
});
it("should retreat into the previous timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the start of the second
// timeline
const timelineIndex = new TimelineIndex(tl2, -1);
const result = timelineIndex.advance(-1);
expect(result).toEqual(-1);
expect(timelineIndex.timeline).toBe(tl1);
expect(timelineIndex.index).toEqual(1);
});
});
describe("retreat", function() {
it("should retreat up to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.retreat(2);
expect(result).toEqual(1);
expect(timelineIndex.index).toEqual(-1);
});
});
});
describe("TimelineWindow", function() {
/**
* create a dummy eventTimelineSet and client, and a TimelineWindow
* attached to them.
*/
let timelineSet;
let client;
function createWindow(timeline, opts) {
timelineSet = { getTimelineForEvent: () => null };
client = {};
client.getEventTimeline = function(timelineSet0, eventId0) {
expect(timelineSet0).toBe(timelineSet);
return Promise.resolve(timeline);
};
return new TimelineWindow(client, timelineSet, opts);
}
describe("load", function() {
it("should initialise from the live timeline", function() {
const liveTimeline = createTimeline();
const room = {};
room.getLiveTimeline = function() {
return liveTimeline;
};
const timelineWindow = new TimelineWindow(undefined, room);
return timelineWindow.load(undefined, 2).then(function() {
const expectedEvents = liveTimeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("should initialise from a specific event", function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null };
const client = {};
client.getEventTimeline = function(timelineSet0, eventId0) {
expect(timelineSet0).toBe(timelineSet);
expect(eventId0).toEqual(eventId);
return Promise.resolve(timeline);
};
const timelineWindow = new TimelineWindow(client, timelineSet);
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("canPaginate should return false until load has returned", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS);
timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS);
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null };
const client = {};
const timelineWindow = new TimelineWindow(client, timelineSet);
client.getEventTimeline = function(timelineSet0, eventId0) {
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return Promise.resolve(timeline);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
});
describe("pagination", function() {
it("should be able to advance across the initial timeline", function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const timelineWindow = createWindow(timeline);
return timelineWindow.load(eventId, 1).then(function() {
const expectedEvents = [timeline.getEvents()[1]];
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
});
});
it("should advance into next timeline", function() {
const tls = createLinkedTimelines();
const eventId = tls[0].getEvents()[1].getId();
const timelineWindow = createWindow(tls[0], { windowLimit: 5 });
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = tls[0].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
// the windowLimit should have made us drop an event from
// tls[0]
const expectedEvents = tls[0].getEvents().slice(1)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
});
});
it("should retreat into previous timeline", function() {
const tls = createLinkedTimelines();
const eventId = tls[1].getEvents()[1].getId();
const timelineWindow = createWindow(tls[1], { windowLimit: 5 });
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = tls[1].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = tls[0].getEvents().slice(1, 3)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
// the windowLimit should have made us drop an event from
// tls[1]
const expectedEvents = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(false);
});
});
it("should make forward pagination requests", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const timelineWindow = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
client.paginateEventTimeline = function(timeline0, opts) {
expect(timeline0).toBe(timeline);
expect(opts.backwards).toBe(false);
expect(opts.limit).toEqual(2);
addEventsToTimeline(timeline, 3, false);
return Promise.resolve(true);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents().slice(0, 5);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("should make backward pagination requests", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.BACKWARDS);
const timelineWindow = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
client.paginateEventTimeline = function(timeline0, opts) {
expect(timeline0).toBe(timeline);
expect(opts.backwards).toBe(true);
expect(opts.limit).toEqual(2);
addEventsToTimeline(timeline, 3, true);
return Promise.resolve(true);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
return timelineWindow.paginate(EventTimeline.BACKWARDS, 2);
}).then(function(success) {
expect(success).toBe(true);
const expectedEvents = timeline.getEvents().slice(1, 6);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
});
it("should limit the number of unsuccessful pagination requests", function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const timelineWindow = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
let paginateCount = 0;
client.paginateEventTimeline = function(timeline0, opts) {
expect(timeline0).toBe(timeline);
expect(opts.backwards).toBe(false);
expect(opts.limit).toEqual(2);
paginateCount += 1;
return Promise.resolve(true);
};
return timelineWindow.load(eventId, 3).then(function() {
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
return timelineWindow.paginate(EventTimeline.FORWARDS, 2, true, 3);
}).then(function(success) {
expect(success).toBe(false);
expect(paginateCount).toEqual(3);
const expectedEvents = timeline.getEvents().slice(0, 3);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
});
});
+439
View File
@@ -0,0 +1,439 @@
/*
Copyright 2022 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 { MockedObject } from 'jest-mock';
import { MatrixClient } from "../../src/client";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
import { Room } from "../../src/models/room";
import { EventTimeline } from "../../src/models/event-timeline";
import { TimelineIndex, TimelineWindow } from "../../src/timeline-window";
import { mkMessage } from "../test-utils/test-utils";
const ROOM_ID = "roomId";
const USER_ID = "userId";
const mockClient = {
getEventTimeline: jest.fn(),
paginateEventTimeline: jest.fn(),
} as unknown as MockedObject<MatrixClient>;
/*
* create a timeline with a bunch (default 3) events.
* baseIndex is 1 by default.
*/
function createTimeline(numEvents = 3, baseIndex = 1): EventTimeline {
const room = new Room(ROOM_ID, mockClient, USER_ID);
const timelineSet = new EventTimelineSet(room);
jest.spyOn(timelineSet.room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet);
const timeline = new EventTimeline(timelineSet);
// add the events after the baseIndex first
addEventsToTimeline(timeline, numEvents - baseIndex, false);
// then add those before the baseIndex
addEventsToTimeline(timeline, baseIndex, true);
expect(timeline.getBaseIndex()).toEqual(baseIndex);
return timeline;
}
function addEventsToTimeline(timeline: EventTimeline, numEvents: number, toStartOfTimeline: boolean) {
for (let i = 0; i < numEvents; i++) {
timeline.addEvent(
mkMessage({
room: ROOM_ID, user: USER_ID,
event: true,
}),
{ toStartOfTimeline },
);
}
}
/*
* create a pair of linked timelines
*/
function createLinkedTimelines(): [EventTimeline, EventTimeline] {
const tl1 = createTimeline();
const tl2 = createTimeline();
tl1.setNeighbouringTimeline(tl2, EventTimeline.FORWARDS);
tl2.setNeighbouringTimeline(tl1, EventTimeline.BACKWARDS);
return [tl1, tl2];
}
describe("TimelineIndex", function() {
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
});
describe("minIndex", function() {
it("should return the min index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.minIndex()).toEqual(-1);
});
});
describe("maxIndex", function() {
it("should return the max index relative to BaseIndex", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
expect(timelineIndex.maxIndex()).toEqual(2);
});
});
describe("advance", function() {
it("should advance up to the end of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(3);
expect(result).toEqual(2);
expect(timelineIndex.index).toEqual(2);
});
it("should retreat back to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.advance(-2);
expect(result).toEqual(-1);
expect(timelineIndex.index).toEqual(-1);
});
it("should advance into the next timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the end of the first timeline
const timelineIndex = new TimelineIndex(tl1, 2);
const result = timelineIndex.advance(1);
expect(result).toEqual(1);
expect(timelineIndex.timeline).toBe(tl2);
// we expect the index to be the zero (ie, the same as the
// BaseIndex), because the BaseIndex points at the second event,
// and we've advanced past the first.
expect(timelineIndex.index).toEqual(0);
});
it("should retreat into the previous timeline", function() {
const timelines = createLinkedTimelines();
const tl1 = timelines[0];
const tl2 = timelines[1];
// initialise the index pointing at the start of the second
// timeline
const timelineIndex = new TimelineIndex(tl2, -1);
const result = timelineIndex.advance(-1);
expect(result).toEqual(-1);
expect(timelineIndex.timeline).toBe(tl1);
expect(timelineIndex.index).toEqual(1);
});
});
describe("retreat", function() {
it("should retreat up to the start of the timeline", function() {
const timelineIndex = new TimelineIndex(createTimeline(), 0);
const result = timelineIndex.retreat(2);
expect(result).toEqual(1);
expect(timelineIndex.index).toEqual(-1);
});
});
});
describe("TimelineWindow", function() {
/**
* create a dummy eventTimelineSet and client, and a TimelineWindow
* attached to them.
*/
function createWindow(timeline: EventTimeline, opts?: {
windowLimit?: number;
}): [TimelineWindow, EventTimelineSet] {
const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet;
mockClient.getEventTimeline.mockResolvedValue(timeline);
return [new TimelineWindow(mockClient, timelineSet, opts), timelineSet];
}
beforeEach(() => {
jest.clearAllMocks();
mockClient.getEventTimeline.mockResolvedValue(undefined);
mockClient.paginateEventTimeline.mockReturnValue(undefined);
});
describe("load", function() {
it("should initialise from the live timeline", async function() {
const liveTimeline = createTimeline();
const room = new Room(ROOM_ID, mockClient, USER_ID);
const timelineSet = new EventTimelineSet(room);
jest.spyOn(timelineSet, 'getLiveTimeline').mockReturnValue(liveTimeline);
const timelineWindow = new TimelineWindow(mockClient, timelineSet);
await timelineWindow.load(undefined, 2);
expect(timelineSet.getLiveTimeline).toHaveBeenCalled();
const expectedEvents = liveTimeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
it("should initialise from a specific event", async function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet;
mockClient.getEventTimeline.mockResolvedValue(timeline);
const timelineWindow = new TimelineWindow(mockClient, timelineSet);
await timelineWindow.load(eventId, 3);
expect(mockClient.getEventTimeline).toHaveBeenCalledWith(timelineSet, eventId);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
});
it("canPaginate should return false until load has returned", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS);
timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS);
const eventId = timeline.getEvents()[1].getId();
const timelineSet = { getTimelineForEvent: () => null } as unknown as EventTimelineSet;
mockClient.getEventTimeline.mockResolvedValue(timeline);
const timelineWindow = new TimelineWindow(mockClient, timelineSet);
const timelineWindowLoadPromise = timelineWindow.load(eventId, 3);
// cannot paginate before load is complete
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false);
// wait for load
await timelineWindowLoadPromise;
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
// can paginate now
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
describe("pagination", function() {
it("should be able to advance across the initial timeline", async function() {
const timeline = createTimeline();
const eventId = timeline.getEvents()[1].getId();
const [timelineWindow] = createWindow(timeline);
await timelineWindow.load(eventId, 1);
const expectedEvents = [timeline.getEvents()[1]];
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
const expectedEventsAfterPagination = timeline.getEvents().slice(1);
expect(timelineWindow.getEvents()).toEqual(expectedEventsAfterPagination);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
// cant paginate forward anymore
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(false);
// paginate back again
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
const expectedEvents3 = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents3);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(false);
});
it("should advance into next timeline", async function() {
const tls = createLinkedTimelines();
const eventId = tls[0].getEvents()[1].getId();
const [timelineWindow] = createWindow(tls[0], { windowLimit: 5 });
await timelineWindow.load(eventId, 3);
const expectedEvents = tls[0].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
const expectedEvents2 = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
// the windowLimit should have made us drop an event from
// tls[0]
const expectedEvents3 = tls[0].getEvents().slice(1)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents3);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(false);
});
it("should retreat into previous timeline", async function() {
const tls = createLinkedTimelines();
const eventId = tls[1].getEvents()[1].getId();
const [timelineWindow] = createWindow(tls[1], { windowLimit: 5 });
await timelineWindow.load(eventId, 3);
const expectedEvents = tls[1].getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
const expectedEvents2 = tls[0].getEvents().slice(1, 3)
.concat(tls[1].getEvents());
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
// the windowLimit should have made us drop an event from
// tls[1]
const expectedEvents3 = tls[0].getEvents()
.concat(tls[1].getEvents().slice(0, 2));
expect(timelineWindow.getEvents()).toEqual(expectedEvents3);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(false);
});
it("should make forward pagination requests", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const [timelineWindow] = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
mockClient.paginateEventTimeline.mockImplementation(async (_t, _opts) => {
addEventsToTimeline(timeline, 3, false);
return true;
});
await timelineWindow.load(eventId, 3);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2)).toBe(true);
expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: false, limit: 2 });
const expectedEvents2 = timeline.getEvents().slice(0, 5);
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
});
it("should make backward pagination requests", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.BACKWARDS);
const [timelineWindow] = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
mockClient.paginateEventTimeline.mockImplementation(async (_t, _opts) => {
addEventsToTimeline(timeline, 3, true);
return true;
});
await timelineWindow.load(eventId, 3);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)).toBe(true);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)).toBe(false);
expect(await timelineWindow.paginate(EventTimeline.BACKWARDS, 2)).toBe(true);
expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: true, limit: 2 });
const expectedEvents2 = timeline.getEvents().slice(1, 6);
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
});
it("should limit the number of unsuccessful pagination requests", async function() {
const timeline = createTimeline();
timeline.setPaginationToken("toktok", EventTimeline.FORWARDS);
const [timelineWindow] = createWindow(timeline, { windowLimit: 5 });
const eventId = timeline.getEvents()[1].getId();
mockClient.paginateEventTimeline.mockImplementation(async (_t, _opts) => {
return true;
});
await timelineWindow.load(eventId, 3);
const expectedEvents = timeline.getEvents();
expect(timelineWindow.getEvents()).toEqual(expectedEvents);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
expect(await timelineWindow.paginate(EventTimeline.FORWARDS, 2, true, 3)).toBe(false);
expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(timeline, { backwards: false, limit: 2 });
expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(3);
const expectedEvents2 = timeline.getEvents().slice(0, 3);
expect(timelineWindow.getEvents()).toEqual(expectedEvents2);
expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS))
.toBe(false);
expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))
.toBe(true);
});
});
});
@@ -1,16 +1,32 @@
import { User } from "../../src/models/user";
import * as utils from "../test-utils/test-utils";
/*
Copyright 2022 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 { User, UserEvent } from "../../src/models/user";
import { mkEvent } from "../test-utils/test-utils";
describe("User", function() {
const userId = "@alice:bar";
let user;
let user: User;
beforeEach(function() {
user = new User(userId);
});
describe("setPresenceEvent", function() {
const event = utils.mkEvent({
const event = mkEvent({
type: "m.presence", content: {
presence: "online",
user_id: userId,
@@ -22,7 +38,7 @@ describe("User", function() {
it("should emit 'User.displayName' if the display name changes", function() {
let emitCount = 0;
user.on("User.displayName", function(ev, usr) {
user.on(UserEvent.DisplayName, function(ev, usr) {
emitCount += 1;
});
user.setPresenceEvent(event);
@@ -33,7 +49,7 @@ describe("User", function() {
it("should emit 'User.avatarUrl' if the avatar URL changes", function() {
let emitCount = 0;
user.on("User.avatarUrl", function(ev, usr) {
user.on(UserEvent.AvatarUrl, function(ev, usr) {
emitCount += 1;
});
user.setPresenceEvent(event);
@@ -44,7 +60,7 @@ describe("User", function() {
it("should emit 'User.presence' if the presence changes", function() {
let emitCount = 0;
user.on("User.presence", function(ev, usr) {
user.on(UserEvent.Presence, function(ev, usr) {
emitCount += 1;
});
user.setPresenceEvent(event);
+80 -12
View File
@@ -10,8 +10,12 @@ import {
prevString,
simpleRetryOperation,
stringToBase,
sortEventsByLatestContentTimestamp,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
import { makeBeaconEvent } from "../test-utils/beacon";
import { ReceiptType } from "../../src/@types/read_receipts";
// TODO: Fix types throughout
@@ -106,18 +110,6 @@ describe("utils", function() {
});
});
describe("checkObjectHasNoAdditionalKeys", function() {
it("should throw for extra keys", function() {
expect(function() {
utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4 }, ["foo"]);
}).toThrow();
expect(function() {
utils.checkObjectHasNoAdditionalKeys({ foo: "bar" }, ["foo"]);
}).not.toThrow();
});
});
describe("deepCompare", function() {
const assert = {
isTrue: function(x: any) {
@@ -506,4 +498,80 @@ describe("utils", function() {
});
});
});
describe('sortEventsByLatestContentTimestamp', () => {
const roomId = '!room:server';
const userId = '@user:server';
const eventWithoutContentTimestamp = mkMessage({ room: roomId, user: userId, event: true });
// m.beacon events have timestamp in content
const beaconEvent1 = makeBeaconEvent(userId, { timestamp: 1648804528557 });
const beaconEvent2 = makeBeaconEvent(userId, { timestamp: 1648804528558 });
const beaconEvent3 = makeBeaconEvent(userId, { timestamp: 1648804528000 });
const beaconEvent4 = makeBeaconEvent(userId, { timestamp: 0 });
it('sorts events with timestamps as later than events without', () => {
expect(
[beaconEvent4, eventWithoutContentTimestamp, beaconEvent1]
.sort(utils.sortEventsByLatestContentTimestamp),
).toEqual([
beaconEvent1, beaconEvent4, eventWithoutContentTimestamp,
]);
});
it('sorts by content timestamps correctly', () => {
expect(
[beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp),
).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]);
});
});
describe('getPrivateReadReceiptField', () => {
it('should return m.read.private if server supports stable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return feature === "org.matrix.msc2285.stable";
}),
} as any)).toBe(ReceiptType.ReadPrivate);
});
it('should return m.read.private if server supports stable and unstable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return ["org.matrix.msc2285.stable", "org.matrix.msc2285"].includes(feature);
}),
} as any)).toBe(ReceiptType.ReadPrivate);
});
it('should return org.matrix.msc2285.read.private if server supports unstable', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
return feature === "org.matrix.msc2285";
}),
} as any)).toBe(ReceiptType.UnstableReadPrivate);
});
it('should return none if server does not support either', async () => {
expect(await utils.getPrivateReadReceiptField({
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
} as any)).toBeFalsy();
});
});
describe('isSupportedReceiptType', () => {
it('should support m.read', () => {
expect(utils.isSupportedReceiptType(ReceiptType.Read)).toBeTruthy();
});
it('should support m.read.private', () => {
expect(utils.isSupportedReceiptType(ReceiptType.ReadPrivate)).toBeTruthy();
});
it('should support org.matrix.msc2285.read.private', () => {
expect(utils.isSupportedReceiptType(ReceiptType.UnstableReadPrivate)).toBeTruthy();
});
it('should not support other receipt types', () => {
expect(utils.isSupportedReceiptType("this is a receipt type")).toBeFalsy();
});
});
});
+387 -140
View File
@@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 2022 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.
@@ -15,119 +15,25 @@ limitations under the License.
*/
import { TestClient } from '../../TestClient';
import { MatrixCall, CallErrorCode, CallEvent } from '../../../src/webrtc/call';
import { MatrixCall, CallErrorCode, CallEvent, supportsMatrixCall, CallType } from '../../../src/webrtc/call';
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes';
import { RoomMember } from "../../../src";
import {
DUMMY_SDP,
MockMediaHandler,
MockMediaStream,
MockMediaStreamTrack,
MockMediaDeviceInfo,
MockRTCPeerConnection,
} from "../../test-utils/webrtc";
import { CallFeed } from "../../../src/webrtc/callFeed";
const DUMMY_SDP = (
"v=0\r\n" +
"o=- 5022425983810148698 2 IN IP4 127.0.0.1\r\n" +
"s=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" +
"a=msid-semantic: WMS h3wAi7s8QpiQMH14WG3BnDbmlOqo9I5ezGZA\r\n" +
"m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126\r\n" +
"c=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:hLDR\r\n" +
"a=ice-pwd:bMGD9aOldHWiI+6nAq/IIlRw\r\n" +
"a=ice-options:trickle\r\n" +
"a=fingerprint:sha-256 E4:94:84:F9:4A:98:8A:56:F5:5F:FD:AF:72:B9:32:89:49:5C:4B:9A:" +
"4A:15:8E:41:8A:F3:69:E4:39:52:DC:D6\r\n" +
"a=setup:active\r\n" +
"a=mid:0\r\n" +
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" +
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" +
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" +
"a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" +
"a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" +
"a=sendrecv\r\n" +
"a=msid:h3wAi7s8QpiQMH14WG3BnDbmlOqo9I5ezGZA 4357098f-3795-4131-bff4-9ba9c0348c49\r\n" +
"a=rtcp-mux\r\n" +
"a=rtpmap:111 opus/48000/2\r\n" +
"a=rtcp-fb:111 transport-cc\r\n" +
"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
"a=rtpmap:103 ISAC/16000\r\n" +
"a=rtpmap:104 ISAC/32000\r\n" +
"a=rtpmap:9 G722/8000\r\n" +
"a=rtpmap:0 PCMU/8000\r\n" +
"a=rtpmap:8 PCMA/8000\r\n" +
"a=rtpmap:106 CN/32000\r\n" +
"a=rtpmap:105 CN/16000\r\n" +
"a=rtpmap:13 CN/8000\r\n" +
"a=rtpmap:110 telephone-event/48000\r\n" +
"a=rtpmap:112 telephone-event/32000\r\n" +
"a=rtpmap:113 telephone-event/16000\r\n" +
"a=rtpmap:126 telephone-event/8000\r\n" +
"a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n"
);
const startVoiceCall = async (client: TestClient, call: MatrixCall): Promise<void> => {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush("");
await callPromise;
class MockRTCPeerConnection {
localDescription: RTCSessionDescription;
constructor() {
this.localDescription = {
sdp: DUMMY_SDP,
type: 'offer',
toJSON: function() {},
};
}
addEventListener() {}
createOffer() {
return Promise.resolve({});
}
setRemoteDescription() {
return Promise.resolve();
}
setLocalDescription() {
return Promise.resolve();
}
close() {}
getStats() { return []; }
addTrack(track: MockMediaStreamTrack) {return new MockRTCRtpSender(track);}
}
class MockRTCRtpSender {
constructor(public track: MockMediaStreamTrack) {}
replaceTrack(track: MockMediaStreamTrack) {this.track = track;}
}
class MockMediaStreamTrack {
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {}
stop() {}
}
class MockMediaStream {
constructor(
public id: string,
private tracks: MockMediaStreamTrack[] = [],
) {}
getTracks() { return this.tracks; }
getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
addEventListener() {}
removeEventListener() { }
addTrack(track: MockMediaStreamTrack) {this.tracks.push(track);}
removeTrack(track: MockMediaStreamTrack) {this.tracks.splice(this.tracks.indexOf(track), 1);}
}
class MockMediaDeviceInfo {
constructor(
public kind: "audio" | "video",
) {}
}
class MockMediaHandler {
getUserMediaStream(audio: boolean, video: boolean) {
const tracks = [];
if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio"));
if (video) tracks.push(new MockMediaStreamTrack("video_track", "video"));
return new MockMediaStream("mock_stream_from_media_handler", tracks);
}
stopUserMediaStream() {}
}
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
};
describe('Call', function() {
let client;
@@ -185,9 +91,8 @@ describe('Call', function() {
});
it('should ignore candidate events from non-matching party ID', async function() {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
await startVoiceCall(client, call);
await call.onAnswerReceived({
getContent: () => {
return {
@@ -241,9 +146,7 @@ describe('Call', function() {
});
it('should add candidates received before answer if party ID is correct', async function() {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
await startVoiceCall(client, call);
call.peerConn.addIceCandidate = jest.fn();
call.onRemoteIceCandidatesReceived({
@@ -301,9 +204,7 @@ describe('Call', function() {
});
it('should map asserted identity messages to remoteAssertedIdentity', async function() {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
await startVoiceCall(client, call);
await call.onAnswerReceived({
getContent: () => {
return {
@@ -345,13 +246,7 @@ describe('Call', function() {
});
it("should map SDPStreamMetadata to feeds", async () => {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
call.getOpponentMember = () => {
return { userId: "@bob:bar.uk" };
};
await startVoiceCall(client, call);
await call.onAnswerReceived({
getContent: () => {
@@ -389,13 +284,7 @@ describe('Call', function() {
});
it("should fallback to replaceTrack() if the other side doesn't support SPDStreamMetadata", async () => {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
call.getOpponentMember = () => {
return { userId: "@bob:bar.uk" } as RoomMember;
};
await startVoiceCall(client, call);
await call.onAnswerReceived({
getContent: () => {
@@ -438,9 +327,7 @@ describe('Call', function() {
),
);
const callPromise = call.placeVideoCall();
await client.httpBackend.flush();
await callPromise;
await startVoiceCall(client, call);
await call.onAnswerReceived({
getContent: () => {
@@ -476,9 +363,7 @@ describe('Call', function() {
});
it("should handle upgrade to video call", async () => {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
await startVoiceCall(client, call);
await call.onAnswerReceived({
getContent: () => {
@@ -505,4 +390,366 @@ describe('Call', function() {
return sender?.track?.kind === "video";
}).track.id).toBe("video_track");
});
it("should handle SDPStreamMetadata changes", async () => {
await startVoiceCall(client, call);
call.updateRemoteSDPStreamMetadata({
"remote_stream": {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: false,
video_muted: false,
},
});
call.pushRemoteFeed(new MockMediaStream("remote_stream", []));
const feed = call.getFeeds().find((feed) => feed.stream.id === "remote_stream");
call.onSDPStreamMetadataChangedReceived({
getContent: () => ({
[SDPStreamMetadataKey]: {
"remote_stream": {
purpose: SDPStreamMetadataPurpose.Screenshare,
audio_muted: true,
video_muted: true,
id: "feed_id2",
},
},
}),
});
expect(feed?.purpose).toBe(SDPStreamMetadataPurpose.Screenshare);
expect(feed?.audioMuted).toBe(true);
expect(feed?.videoMuted).toBe(true);
});
it("should choose opponent member", async () => {
const callPromise = call.placeVoiceCall();
await client.httpBackend.flush();
await callPromise;
const opponentMember = {
roomId: call.roomId,
userId: "opponentUserId",
};
const opponentCaps = {
"m.call.transferee": true,
"m.call.dtmf": false,
};
call.chooseOpponent({
getContent: () => ({
version: 1,
party_id: "party_id",
capabilities: opponentCaps,
}),
sender: opponentMember,
});
expect(call.getOpponentMember()).toBe(opponentMember);
expect(call.opponentPartyId).toBe("party_id");
expect(call.opponentCaps).toBe(opponentCaps);
expect(call.opponentCanBeTransferred()).toBe(true);
expect(call.opponentSupportsDTMF()).toBe(false);
});
describe("should deduce the call type correctly", () => {
it("if no video", async () => {
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
call.pushRemoteFeed(new MockMediaStream("remote_stream1", []));
expect(call.type).toBe(CallType.Voice);
});
it("if remote video", async () => {
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
call.pushRemoteFeed(new MockMediaStream("remote_stream1", [new MockMediaStreamTrack("track_id", "video")]));
expect(call.type).toBe(CallType.Video);
});
it("if local video", async () => {
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
call.pushNewLocalFeed(
new MockMediaStream("remote_stream1", [new MockMediaStreamTrack("track_id", "video")]),
SDPStreamMetadataPurpose.Usermedia,
false,
);
expect(call.type).toBe(CallType.Video);
});
});
it("should correctly generate local SDPStreamMetadata", async () => {
const callPromise = call.placeCallWithCallFeeds([new CallFeed({
client,
// @ts-ignore Mock
stream: new MockMediaStream("local_stream1", [new MockMediaStreamTrack("track_id", "audio")]),
roomId: call.roomId,
userId: client.getUserId(),
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
})]);
await client.httpBackend.flush();
await callPromise;
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
call.pushNewLocalFeed(
new MockMediaStream("local_stream2", [new MockMediaStreamTrack("track_id", "video")]),
SDPStreamMetadataPurpose.Screenshare, "feed_id2",
);
await call.setMicrophoneMuted(true);
expect(call.getLocalSDPStreamMetadata()).toStrictEqual({
"local_stream1": {
"purpose": SDPStreamMetadataPurpose.Usermedia,
"audio_muted": true,
"video_muted": true,
},
"local_stream2": {
"purpose": SDPStreamMetadataPurpose.Screenshare,
"audio_muted": true,
"video_muted": false,
},
});
});
it("feed and stream getters return correctly", async () => {
const localUsermediaStream = new MockMediaStream("local_usermedia_stream_id", []);
const localScreensharingStream = new MockMediaStream("local_screensharing_stream_id", []);
const remoteUsermediaStream = new MockMediaStream("remote_usermedia_stream_id", []);
const remoteScreensharingStream = new MockMediaStream("remote_screensharing_stream_id", []);
const callPromise = call.placeCallWithCallFeeds([
new CallFeed({
client,
userId: client.getUserId(),
// @ts-ignore Mock
stream: localUsermediaStream,
purpose: SDPStreamMetadataPurpose.Usermedia,
id: "local_usermedia_feed_id",
audioMuted: false,
videoMuted: false,
}),
new CallFeed({
client,
userId: client.getUserId(),
// @ts-ignore Mock
stream: localScreensharingStream,
purpose: SDPStreamMetadataPurpose.Screenshare,
id: "local_screensharing_feed_id",
audioMuted: false,
videoMuted: false,
}),
]);
await client.httpBackend.flush();
await callPromise;
call.getOpponentMember = jest.fn().mockReturnValue({ userId: "@bob:bar.uk" });
call.updateRemoteSDPStreamMetadata({
"remote_usermedia_stream_id": {
purpose: SDPStreamMetadataPurpose.Usermedia,
id: "remote_usermedia_feed_id",
audio_muted: false,
video_muted: false,
},
"remote_screensharing_stream_id": {
purpose: SDPStreamMetadataPurpose.Screenshare,
id: "remote_screensharing_feed_id",
audio_muted: false,
video_muted: false,
},
});
call.pushRemoteFeed(remoteUsermediaStream);
call.pushRemoteFeed(remoteScreensharingStream);
expect(call.localUsermediaFeed.stream).toBe(localUsermediaStream);
expect(call.localUsermediaStream).toBe(localUsermediaStream);
expect(call.localScreensharingFeed.stream).toBe(localScreensharingStream);
expect(call.localScreensharingStream).toBe(localScreensharingStream);
expect(call.remoteUsermediaFeed.stream).toBe(remoteUsermediaStream);
expect(call.remoteUsermediaStream).toBe(remoteUsermediaStream);
expect(call.remoteScreensharingFeed.stream).toBe(remoteScreensharingStream);
expect(call.remoteScreensharingStream).toBe(remoteScreensharingStream);
expect(call.hasRemoteUserMediaAudioTrack).toBe(false);
});
it("should end call after receiving a select event with a different party id", async () => {
const callPromise = call.initWithInvite({
getContent: () => ({
version: 1,
call_id: "call_id",
party_id: "remote_party_id",
offer: {
sdp: DUMMY_SDP,
},
}),
getLocalAge: () => null,
});
call.feeds.push(new CallFeed({
client,
userId: "remote_user_id",
// @ts-ignore Mock
stream: new MockMediaStream("remote_stream_id", [new MockMediaStreamTrack("remote_tack_id")]),
id: "remote_feed_id",
purpose: SDPStreamMetadataPurpose.Usermedia,
}));
await client.httpBackend.flush();
await callPromise;
const callHangupCallback = jest.fn();
call.on(CallEvent.Hangup, callHangupCallback);
await call.onSelectAnswerReceived({
getContent: () => ({
version: 1,
call_id: call.callId,
party_id: 'party_id',
selected_party_id: "different_party_id",
}),
});
expect(callHangupCallback).toHaveBeenCalled();
});
describe("turn servers", () => {
it("should fallback if allowed", async () => {
client.client.isFallbackICEServerAllowed = () => true;
const localCall = new MatrixCall({
client: client.client,
roomId: '!room_id',
});
expect((localCall as any).turnServers).toStrictEqual([{ urls: ["stun:turn.matrix.org"] }]);
});
it("should not fallback if not allowed", async () => {
client.client.isFallbackICEServerAllowed = () => false;
const localCall = new MatrixCall({
client: client.client,
roomId: '!room_id',
});
expect((localCall as any).turnServers).toStrictEqual([]);
});
it("should not fallback if we supplied turn servers", async () => {
client.client.isFallbackICEServerAllowed = () => true;
const turnServers = [{ urls: ["turn.server.org"] }];
const localCall = new MatrixCall({
client: client.client,
roomId: '!room_id',
turnServers,
});
expect((localCall as any).turnServers).toStrictEqual(turnServers);
});
});
it("should handle creating a data channel", async () => {
await startVoiceCall(client, call);
const dataChannelCallback = jest.fn();
call.on(CallEvent.DataChannel, dataChannelCallback);
const dataChannel = call.createDataChannel("data_channel_label", { id: 123 });
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel);
expect(dataChannel.label).toBe("data_channel_label");
expect(dataChannel.id).toBe(123);
});
describe("supportsMatrixCall", () => {
it("should return true when the environment is right", () => {
expect(supportsMatrixCall()).toBe(true);
});
it("should return false if window or document are undefined", () => {
global.window = undefined;
expect(supportsMatrixCall()).toBe(false);
global.window = prevWindow;
global.document = undefined;
expect(supportsMatrixCall()).toBe(false);
});
it("should return false if RTCPeerConnection throws", () => {
// @ts-ignore - writing to window as we are simulating browser edge-cases
global.window = {};
Object.defineProperty(global.window, "RTCPeerConnection", {
get: () => {
throw Error("Secure mode, naaah!");
},
});
expect(supportsMatrixCall()).toBe(false);
});
it("should return false if RTCPeerConnection & RTCSessionDescription " +
"& RTCIceCandidate & mediaDevices are unavailable",
() => {
global.window.RTCPeerConnection = undefined;
global.window.RTCSessionDescription = undefined;
global.window.RTCIceCandidate = undefined;
// @ts-ignore - writing to a read-only property as we are simulating faulty browsers
global.navigator.mediaDevices = undefined;
expect(supportsMatrixCall()).toBe(false);
});
});
describe("ignoring streams with ids for which we already have a feed", () => {
const STREAM_ID = "stream_id";
const FEEDS_CHANGED_CALLBACK = jest.fn();
beforeEach(async () => {
await startVoiceCall(client, call);
call.on(CallEvent.FeedsChanged, FEEDS_CHANGED_CALLBACK);
jest.spyOn(call, "pushLocalFeed");
});
afterEach(() => {
FEEDS_CHANGED_CALLBACK.mockReset();
});
it("should ignore stream passed to pushRemoteFeed()", async () => {
await call.onAnswerReceived({
getContent: () => {
return {
version: 1,
call_id: call.callId,
party_id: 'party_id',
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[STREAM_ID]: {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
},
};
},
});
call.pushRemoteFeed(new MockMediaStream(STREAM_ID));
call.pushRemoteFeed(new MockMediaStream(STREAM_ID));
expect(call.getRemoteFeeds().length).toBe(1);
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
});
it("should ignore stream passed to pushRemoteFeedWithoutMetadata()", async () => {
call.pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
call.pushRemoteFeedWithoutMetadata(new MockMediaStream(STREAM_ID));
expect(call.getRemoteFeeds().length).toBe(1);
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
});
it("should ignore stream passed to pushNewLocalFeed()", async () => {
call.pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
call.pushNewLocalFeed(new MockMediaStream(STREAM_ID), SDPStreamMetadataPurpose.Screenshare);
// We already have one local feed from placeVoiceCall()
expect(call.getLocalFeeds().length).toBe(2);
expect(FEEDS_CHANGED_CALLBACK).toHaveBeenCalledTimes(1);
expect(call.pushLocalFeed).toHaveBeenCalled();
});
});
});
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright 2022 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 { TestClient } from '../../TestClient';
import {
ClientEvent,
EventTimeline,
EventTimelineSet,
EventType,
IRoomTimelineData,
MatrixEvent,
Room,
RoomEvent,
} from "../../../src";
import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
import { SyncState } from "../../../src/sync";
describe("callEventHandler", () => {
it("should ignore a call if invite & hangup come within a single sync", () => {
const testClient = new TestClient();
const client = testClient.client;
const room = new Room("!room:id", client, "@user:id");
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
client.callEventHandler = new CallEventHandler(client);
client.callEventHandler.start();
// Fire off call invite then hangup within a single sync
const callInvite = new MatrixEvent({
type: EventType.CallInvite,
content: {
call_id: "123",
},
});
client.emit(RoomEvent.Timeline, callInvite, room, false, false, timelineData);
const callHangup = new MatrixEvent({
type: EventType.CallHangup,
content: {
call_id: "123",
},
});
client.emit(RoomEvent.Timeline, callHangup, room, false, false, timelineData);
const incomingCallEmitted = jest.fn();
client.on(CallEventHandlerEvent.Incoming, incomingCallEmitted);
client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing);
expect(incomingCallEmitted).not.toHaveBeenCalled();
});
});
+89
View File
@@ -0,0 +1,89 @@
/*
Copyright 2022 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 { SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
import { CallFeed } from "../../../src/webrtc/callFeed";
import { TestClient } from "../../TestClient";
import { MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
describe("CallFeed", () => {
let client;
beforeEach(() => {
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {});
});
afterEach(() => {
client.stop();
});
describe("muting", () => {
let feed: CallFeed;
beforeEach(() => {
feed = new CallFeed({
client,
roomId: "room1",
userId: "user1",
// @ts-ignore Mock
stream: new MockMediaStream("stream1"),
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: false,
});
});
describe("muting by default", () => {
it("should mute audio by default", () => {
expect(feed.isAudioMuted()).toBeTruthy();
});
it("should mute video by default", () => {
expect(feed.isVideoMuted()).toBeTruthy();
});
});
describe("muting after adding a track", () => {
it("should un-mute audio", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
expect(feed.isAudioMuted()).toBeFalsy();
});
it("should un-mute video", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "video", true));
expect(feed.isVideoMuted()).toBeFalsy();
});
});
describe("muting after calling setAudioVideoMuted()", () => {
it("should mute audio by default ", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "audio", true));
feed.setAudioVideoMuted(true, false);
expect(feed.isAudioMuted()).toBeTruthy();
});
it("should mute video by default", () => {
// @ts-ignore Mock
feed.stream.addTrack(new MockMediaStreamTrack("track", "video", true));
feed.setAudioVideoMuted(false, true);
expect(feed.isVideoMuted()).toBeTruthy();
});
});
});
});
+9 -19
View File
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd, RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
/**
* Beacon info and beacon event types as described in MSC3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* Beacon info and beacon event types as described in MSC3672
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
*/
/**
@@ -60,16 +60,11 @@ import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
* }
*/
/**
* Variable event type for m.beacon_info
*/
export const M_BEACON_INFO_VARIABLE = new UnstableValue("m.beacon_info.*", "org.matrix.msc3489.beacon_info.*");
/**
* Non-variable type for m.beacon_info event content
*/
export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3489.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3489.beacon");
export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon");
export type MBeaconInfoContent = {
description?: string;
@@ -80,16 +75,11 @@ export type MBeaconInfoContent = {
live?: boolean;
};
export type MBeaconInfoEvent = EitherAnd<
{ [M_BEACON_INFO.name]: MBeaconInfoContent },
{ [M_BEACON_INFO.altName]: MBeaconInfoContent }
>;
/**
* m.beacon_info Event example from the spec
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
* {
"type": "m.beacon_info.@matthew:matrix.org.1",
"type": "m.beacon_info",
"state_key": "@matthew:matrix.org",
"content": {
"m.beacon_info": {
@@ -108,7 +98,7 @@ export type MBeaconInfoEvent = EitherAnd<
* m.beacon_info.* event content
*/
export type MBeaconInfoEventContent = &
MBeaconInfoEvent &
MBeaconInfoContent &
// creation timestamp of the beacon on the client
MTimestampEvent &
// the type of asset being tracked as per MSC3488
@@ -116,7 +106,7 @@ export type MBeaconInfoEventContent = &
/**
* m.beacon event example
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
*
* {
"type": "m.beacon",
+8
View File
@@ -151,6 +151,14 @@ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc
*/
export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch");
/**
* Marker event type to point back at imported historical content in a room. See
* [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716).
* Note that this reference is UNSTABLE and subject to breaking changes,
* including its eventual removal.
*/
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
/**
* Functional members type for declaring a purpose of room members (e.g. helpful bots).
* Note that this reference is UNSTABLE and subject to breaking changes, including its
+1
View File
@@ -23,6 +23,7 @@ declare global {
// use `number` as the return type in all cases for global.set{Interval,Timeout},
// so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments.
// The overload for clear{Interval,Timeout} is resolved as expected.
// We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded.
function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
+25
View File
@@ -0,0 +1,25 @@
/*
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
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.
*/
export enum ReceiptType {
Read = "m.read",
FullyRead = "m.fully_read",
ReadPrivate = "m.read.private",
/**
* @deprecated Please use the ReadPrivate type when possible. This value may be removed at any time without notice.
*/
UnstableReadPrivate = "org.matrix.msc2285.read.private",
}
+52 -2
View File
@@ -17,9 +17,12 @@ limitations under the License.
import { Callback } from "../client";
import { IContent, IEvent } from "../models/event";
import { Preset, Visibility } from "./partials";
import { SearchKey } from "./search";
import { IEventWithRoomId, SearchKey } from "./search";
import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator";
import { RoomType } from "./event";
// allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */
@@ -109,7 +112,8 @@ export interface IRoomDirectoryOptions {
limit?: number;
since?: string;
filter?: {
generic_search_term: string;
generic_search_term?: string;
room_types?: Array<RoomType | null>;
};
include_all_networks?: boolean;
third_party_instance_id?: string;
@@ -155,4 +159,50 @@ export interface IRelationsResponse {
prev_batch?: string;
}
export interface IContextResponse {
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
}
export interface IEventsResponse {
chunk: IEventWithRoomId[];
end: string;
start: string;
}
export interface INotification {
actions: PushRuleAction[];
event: IRoomEvent;
profile_tag?: string;
read: boolean;
room_id: string;
ts: number;
}
export interface INotificationsResponse {
next_token: string;
notifications: INotification[];
}
export interface IFilterResponse {
filter_id: string;
}
export interface ITagsResponse {
tags: {
[tagId: string]: {
order: number;
};
};
}
export interface IStatusResponse extends IPresenceOpts {
currently_active?: boolean;
last_active_ago?: number;
}
/* eslint-enable camelcase */
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2022 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 { EitherAnd, IMessageRendering } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
/**
* Extensible topic event type based on MSC3765
* https://github.com/matrix-org/matrix-spec-proposals/pull/3765
*/
/**
* Eg
* {
* "type": "m.room.topic,
* "state_key": "",
* "content": {
* "topic": "All about **pizza**",
* "m.topic": [{
* "body": "All about **pizza**",
* "mimetype": "text/plain",
* }, {
* "body": "All about <b>pizza</b>",
* "mimetype": "text/html",
* }],
* }
* }
*/
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = IMessageRendering[];
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>;
/**
* The event content for an m.room.topic event
*/
export type MRoomTopicEventContent = { topic: string } & MTopicEvent;
+125
View File
@@ -0,0 +1,125 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "./logger";
import { MatrixClient } from "./matrix";
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
import { MatrixScheduler } from "./scheduler";
const MAX_BATCH_SIZE = 20;
/**
* Maintains a queue of outgoing to-device messages, sending them
* as soon as the homeserver is reachable.
*/
export class ToDeviceMessageQueue {
private sending = false;
private running = true;
private retryTimeout: number = null;
private retryAttempts = 0;
constructor(private client: MatrixClient) {
}
public start(): void {
this.running = true;
this.sendQueue();
}
public stop(): void {
this.running = false;
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
public async queueBatch(batch: ToDeviceBatch): Promise<void> {
const batches: ToDeviceBatchWithTxnId[] = [];
for (let i = 0; i < batch.batch.length; i += MAX_BATCH_SIZE) {
batches.push({
eventType: batch.eventType,
batch: batch.batch.slice(i, i + MAX_BATCH_SIZE),
txnId: this.client.makeTxnId(),
});
}
await this.client.store.saveToDeviceBatches(batches);
this.sendQueue();
}
public sendQueue = async (): Promise<void> => {
if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
this.retryTimeout = null;
if (this.sending || !this.running) return;
logger.debug("Attempting to send queued to-device messages");
this.sending = true;
let headBatch;
try {
while (this.running) {
headBatch = await this.client.store.getOldestToDeviceBatch();
if (headBatch === null) break;
await this.sendBatch(headBatch);
await this.client.store.removeToDeviceBatch(headBatch.id);
this.retryAttempts = 0;
}
// Make sure we're still running after the async tasks: if not, stop.
if (!this.running) return;
logger.debug("All queued to-device messages sent");
} catch (e) {
++this.retryAttempts;
// eslint-disable-next-line @typescript-eslint/naming-convention
// eslint-disable-next-line new-cap
const retryDelay = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, e);
if (retryDelay === -1) {
// the scheduler function doesn't differentiate between fatal errors and just getting
// bored and giving up for now
if (Math.floor(e.httpStatus / 100) === 4) {
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
await this.client.store.removeToDeviceBatch(headBatch.id);
} else {
logger.info("Automatic retry limit reached for to-device messages.");
}
return;
}
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
} finally {
this.sending = false;
}
};
/**
* Attempts to send a batch of to-device messages.
*/
private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> {
const contentMap: Record<string, Record<string, ToDevicePayload>> = {};
for (const item of batch.batch) {
if (!contentMap[item.userId]) {
contentMap[item.userId] = {};
}
contentMap[item.userId][item.deviceId] = item.payload;
}
logger.info(`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id}`);
await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId);
}
}
+18 -26
View File
@@ -17,8 +17,6 @@ limitations under the License.
/** @module auto-discovery */
import { URL as NodeURL } from "url";
import { IClientWellKnown, IWellKnownConfig } from "./client";
import { logger } from './logger';
@@ -249,21 +247,20 @@ export class AutoDiscovery {
// Step 7: Copy any other keys directly into the clientConfig. This is for
// things like custom configuration of services.
Object.keys(wellknown)
.map((k) => {
if (k === "m.homeserver" || k === "m.identity_server") {
// Only copy selected parts of the config to avoid overwriting
// properties computed by the validation logic above.
const notProps = ["error", "state", "base_url"];
for (const prop of Object.keys(wellknown[k])) {
if (notProps.includes(prop)) continue;
clientConfig[k][prop] = wellknown[k][prop];
}
} else {
// Just copy the whole thing over otherwise
clientConfig[k] = wellknown[k];
Object.keys(wellknown).forEach((k) => {
if (k === "m.homeserver" || k === "m.identity_server") {
// Only copy selected parts of the config to avoid overwriting
// properties computed by the validation logic above.
const notProps = ["error", "state", "base_url"];
for (const prop of Object.keys(wellknown[k])) {
if (notProps.includes(prop)) continue;
clientConfig[k][prop] = wellknown[k][prop];
}
});
} else {
// Just copy the whole thing over otherwise
clientConfig[k] = wellknown[k];
}
});
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
@@ -373,16 +370,11 @@ export class AutoDiscovery {
if (!url) return false;
try {
// We have to try and parse the URL using the NodeJS URL
// library if we're on NodeJS and use the browser's URL
// library when we're in a browser. To accomplish this, we
// try the NodeJS version first and fall back to the browser.
let parsed = null;
try {
if (NodeURL) parsed = new NodeURL(url);
else parsed = new URL(url);
} catch (e) {
parsed = new URL(url);
} catch (e) {
logger.error("Could not parse url", e);
}
if (!parsed || !parsed.hostname) return false;
@@ -411,14 +403,14 @@ export class AutoDiscovery {
* the following properties:
* raw: The JSON object returned by the server.
* action: One of SUCCESS, IGNORE, or FAIL_PROMPT.
* reason: Relatively human readable description of what went wrong.
* reason: Relatively human-readable description of what went wrong.
* error: The actual Error, if one exists.
* @param {string} url The URL to fetch a JSON object from.
* @return {Promise<object>} Resolves to the returned state.
* @private
*/
private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
return new Promise(function(resolve, reject) {
private static fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
return new Promise(function(resolve) {
// eslint-disable-next-line
const request = require("./matrix").getRequest();
if (!request) throw new Error("No request library available");
+544 -334
View File
File diff suppressed because it is too large Load Diff
+55 -13
View File
@@ -16,9 +16,9 @@ limitations under the License.
/** @module ContentHelpers */
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
import { MsgType } from "./@types/event";
import { TEXT_NODE_TYPE } from "./@types/extensible_events";
import {
@@ -32,6 +32,7 @@ import {
MAssetContent,
LegacyLocationEventContent,
} from "./@types/location";
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
/**
* Generates the content for a HTML Message event
@@ -138,10 +139,10 @@ export const getTextForLocationEvent = (
/**
* Generates the content for a Location event
* @param uri a geo:// uri for the location
* @param ts the timestamp when the location was correct (milliseconds since
* @param timestamp the timestamp when the location was correct (milliseconds since
* the UNIX epoch)
* @param description the (optional) label for this location on the map
* @param asset_type the (optional) asset type of this location e.g. "m.self"
* @param assetType the (optional) asset type of this location e.g. "m.self"
* @param text optional. A text for the location
*/
export const makeLocationContent = (
@@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType);
};
/**
* Topic event helpers
*/
export type MakeTopicContent = (
topic: string,
htmlTopic?: string,
) => MRoomTopicEventContent;
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [{ body: topic, mimetype: "text/plain" }];
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
}
return { topic, [M_TOPIC.name]: renderings };
};
export type TopicState = {
text: string;
html?: string;
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
const html = mtopic?.find(r => r.mimetype === "text/html")?.body;
return { text, html };
};
/**
* Beacon event helpers
*/
@@ -208,11 +237,9 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
assetType,
timestamp,
) => ({
[M_BEACON_INFO.name]: {
description,
timeout,
live: isLive,
},
description,
timeout,
live: isLive,
[M_TIMESTAMP.name]: timestamp || Date.now(),
[M_ASSET.name]: {
type: assetType ?? LocationAssetType.Self,
@@ -227,7 +254,7 @@ export type BeaconInfoState = MBeaconInfoContent & {
* Flatten beacon info event content
*/
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
const { description, timeout, live } = M_BEACON_INFO.findIn<MBeaconInfoContent>(content);
const { description, timeout, live } = content;
const { type: assetType } = M_ASSET.findIn<MAssetContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
@@ -243,14 +270,14 @@ export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): Beacon
export type MakeBeaconContent = (
uri: string,
timestamp: number,
beaconInfoId: string,
beaconInfoEventId: string,
description?: string,
) => MBeaconEventContent;
export const makeBeaconContent: MakeBeaconContent = (
uri,
timestamp,
beaconInfoId,
beaconInfoEventId,
description,
) => ({
[M_LOCATION.name]: {
@@ -260,6 +287,21 @@ export const makeBeaconContent: MakeBeaconContent = (
[M_TIMESTAMP.name]: timestamp,
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: beaconInfoId,
event_id: beaconInfoEventId,
},
});
export type BeaconLocationState = MLocationContent & {
timestamp: number;
};
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
const { description, uri } = M_LOCATION.findIn<MLocationContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
return {
description,
uri,
timestamp,
};
};

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