Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f03fbed668 | |||
| c84efb57ef | |||
| 7b316613f6 | |||
| 387b3485ae | |||
| 9f6073478f | |||
| b47c87f909 | |||
| c66850e897 | |||
| 2766146c49 | |||
| 61497c9a8f | |||
| 5981feeb44 | |||
| 51218ddc1d | |||
| b907433d38 | |||
| 32c0b81332 | |||
| 634b8ebbb4 | |||
| 4ab8066e1f | |||
| 13dccb3d71 | |||
| ef1f5bf232 | |||
| a03e3dd501 | |||
| 3cfad3cdeb | |||
| 60c715d5df | |||
| c2942ddbc7 | |||
| 2d7fdde7ed | |||
| cf34e90cb4 | |||
| f0fa4d2cc8 | |||
| c52e4b6329 | |||
| 9451b55985 | |||
| f41fa84e72 | |||
| 3c4134537a | |||
| 39714bfe6f | |||
| f5f6100b1e | |||
| ecd700a36e | |||
| ea7042efb9 | |||
| 04a6c4e6c4 | |||
| 0329824cab | |||
| 3351c4f57a | |||
| e70a1a1eff | |||
| 1a5af9d8e3 | |||
| 258f157ebc | |||
| dfb079a76f | |||
| 858155e0ef | |||
| 946a1cef0f | |||
| 6c1fdbb7e9 | |||
| 8cd7c96496 | |||
| cc9d530bcf | |||
| cabf6da6a7 | |||
| 895a82efcd | |||
| d4414341d6 | |||
| f173014fc0 | |||
| 81cb44db7f | |||
| 71f9b25db7 | |||
| 9c5c7ddb17 | |||
| feb424b0a9 | |||
| 648a1a09b1 | |||
| 56c5375bbd | |||
| b29e1e9d21 | |||
| 7ade461a4c | |||
| b5414ea914 | |||
| 711bf4710d | |||
| 48b60bb885 | |||
| 7ed14dc749 | |||
| 26736b6bb1 | |||
| 056aae823d | |||
| d1bfdca0c9 | |||
| c0577c29c4 | |||
| a2d1dee0a1 | |||
| d7bcdff29b | |||
| ed71cdeccd | |||
| 729f924de1 | |||
| 4732098731 | |||
| a7d503a8ac | |||
| 063d69eff1 | |||
| 614f446361 | |||
| b62fac9dad | |||
| ff07cb642f | |||
| fba3cf2756 | |||
| 5973c66726 | |||
| b22fc1f9d9 | |||
| 3f48a954d8 | |||
| b5d544df68 | |||
| cd26ba67d4 | |||
| 488390365a | |||
| b7b1129478 | |||
| 0fa9528a3f | |||
| a7b1dcaf95 | |||
| bb5bccbf78 | |||
| ece3ccb958 | |||
| a769cf88f7 | |||
| 8b0f1a0c59 | |||
| 978e748246 | |||
| 32a5cc4728 | |||
| d580f56c0b | |||
| 789458e0d4 | |||
| 8d14d45272 |
@@ -0,0 +1,31 @@
|
||||
# Triggers after the "Downstream artifacts" build has finished, to run the
|
||||
# cypress tests (with access to repo secrets)
|
||||
|
||||
name: matrix-react-sdk Cypress End to End Tests
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build downstream artifacts"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
cypress:
|
||||
name: Cypress
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@develop
|
||||
permissions:
|
||||
actions: read
|
||||
issues: read
|
||||
statuses: write
|
||||
pull-requests: read
|
||||
secrets:
|
||||
# secrets are not automatically shared with called workflows, so share the cypress dashboard key, and the Kiwi login details
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
|
||||
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
|
||||
with:
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
rust-crypto: true
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v1
|
||||
uses: matrix-org/netlify-pr-preview@v2
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Build downstream artifacts
|
||||
on:
|
||||
pull_request: {}
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
# For now at least, we don't run this or the cypress-tests against pushes
|
||||
# to develop or master.
|
||||
#
|
||||
# Note that if we later choose to do so, we'll need to find a way to stop
|
||||
# the results in Cypress Cloud from clobbering those from the 'develop'
|
||||
# branch of matrix-react-sdk.
|
||||
#
|
||||
#push:
|
||||
# branches: [develop, master]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build-element-web:
|
||||
name: Build element-web
|
||||
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@develop
|
||||
with:
|
||||
matrix-js-sdk-sha: ${{ github.sha }}
|
||||
react-sdk-repository: matrix-org/matrix-react-sdk
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
|
||||
prepare:
|
||||
name: Prepare
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
sonarqube:
|
||||
name: 🩻 SonarQube
|
||||
if: github.event.workflow_run.event != 'merge_group'
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
|
||||
needs: prepare
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||
secrets:
|
||||
|
||||
@@ -24,3 +24,6 @@ out
|
||||
|
||||
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
|
||||
/CHANGELOG.md
|
||||
|
||||
# This file is also autogenerated
|
||||
/spec/test-utils/test-data/index.ts
|
||||
|
||||
+44
-3
@@ -1,10 +1,46 @@
|
||||
Changes in [25.2.0-rc.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.2.0-rc.4) (2023-05-16)
|
||||
============================================================================================================
|
||||
Changes in [26.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.1.0) (2023-06-20)
|
||||
==================================================================================================
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Deprecate device methods in MatrixClient ([\#3357](https://github.com/matrix-org/matrix-js-sdk/pull/3357)).
|
||||
* Introduce a new `Crypto.Verifier` interface, and deprecate direct access to `VerificationBase`, `SAS` and `ReciprocateQRCode` ([\#3414](https://github.com/matrix-org/matrix-js-sdk/pull/3414)).
|
||||
|
||||
## ✨ Features
|
||||
* Add `rust-crypto#isCrossSigningReady` implementation ([\#3462](https://github.com/matrix-org/matrix-js-sdk/pull/3462)). Contributed by @florianduros.
|
||||
* OIDC: Validate `m.authentication` configuration ([\#3419](https://github.com/matrix-org/matrix-js-sdk/pull/3419)). Contributed by @kerryarchibald.
|
||||
* ElementR: Add `CryptoApi.getCrossSigningStatus` ([\#3452](https://github.com/matrix-org/matrix-js-sdk/pull/3452)). Contributed by @florianduros.
|
||||
* Extend stats summary with call device and user count based on room state ([\#3424](https://github.com/matrix-org/matrix-js-sdk/pull/3424)). Contributed by @toger5.
|
||||
* Update MSC3912 implementation to use `with_rel_type` instead of `with_relations` ([\#3420](https://github.com/matrix-org/matrix-js-sdk/pull/3420)).
|
||||
* Export thread-related types from SDK ([\#3447](https://github.com/matrix-org/matrix-js-sdk/pull/3447)). Contributed by @stas-demydiuk.
|
||||
* Use correct /v3 prefix for /refresh ([\#3016](https://github.com/matrix-org/matrix-js-sdk/pull/3016)). Contributed by @davidisaaclee.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix thread list being ordered based on all updates ([\#3458](https://github.com/matrix-org/matrix-js-sdk/pull/3458)). Fixes vector-im/element-web#25522.
|
||||
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
|
||||
* use cli.canSupport to determine intentional mentions support ([\#3445](https://github.com/matrix-org/matrix-js-sdk/pull/3445)). Fixes vector-im/element-web#25497. Contributed by @kerryarchibald.
|
||||
* Make sliding sync linearize processing of sync requests ([\#3442](https://github.com/matrix-org/matrix-js-sdk/pull/3442)).
|
||||
* Fix edge cases around 2nd order relations and threads ([\#3437](https://github.com/matrix-org/matrix-js-sdk/pull/3437)).
|
||||
|
||||
Changes in [26.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.1) (2023-06-09)
|
||||
==================================================================================================
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix: handle `baseUrl` with trailing slash in `fetch.getUrl` ([\#3455](https://github.com/matrix-org/matrix-js-sdk/pull/3455)). Fixes vector-im/element-web#25526. Contributed by @kerryarchibald.
|
||||
|
||||
Changes in [26.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.0) (2023-06-06)
|
||||
==================================================================================================
|
||||
|
||||
## 🚨 BREAKING CHANGES
|
||||
* Ensure we do not add relations to the wrong timeline ([\#3427](https://github.com/matrix-org/matrix-js-sdk/pull/3427)). Fixes vector-im/element-web#25450 and vector-im/element-web#25494.
|
||||
* Deprecate `QrCodeEvent`, `SasEvent` and `VerificationEvent` ([\#3386](https://github.com/matrix-org/matrix-js-sdk/pull/3386)).
|
||||
|
||||
## 🦖 Deprecations
|
||||
* Move crypto classes into a separate namespace ([\#3385](https://github.com/matrix-org/matrix-js-sdk/pull/3385)).
|
||||
|
||||
## ✨ Features
|
||||
* Mention deno support in the README ([\#3417](https://github.com/matrix-org/matrix-js-sdk/pull/3417)). Contributed by @sigmaSd.
|
||||
* Mark room version 10 as safe ([\#3425](https://github.com/matrix-org/matrix-js-sdk/pull/3425)).
|
||||
* Prioritise entirely supported flows for UIA ([\#3402](https://github.com/matrix-org/matrix-js-sdk/pull/3402)).
|
||||
* Add methods to terminate idb worker ([\#3362](https://github.com/matrix-org/matrix-js-sdk/pull/3362)).
|
||||
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
|
||||
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
|
||||
|
||||
@@ -13,6 +49,11 @@ Changes in [25.2.0-rc.4](https://github.com/matrix-org/matrix-js-sdk/releases/ta
|
||||
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
|
||||
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
|
||||
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
|
||||
* Remove spec non-compliant extended glob format ([\#3423](https://github.com/matrix-org/matrix-js-sdk/pull/3423)). Fixes vector-im/element-web#25474.
|
||||
* Fix bug where original event was inserted into timeline instead of the edit event ([\#3398](https://github.com/matrix-org/matrix-js-sdk/pull/3398)). Contributed by @andybalaam.
|
||||
* Only add a local receipt if it's after an existing receipt ([\#3399](https://github.com/matrix-org/matrix-js-sdk/pull/3399)). Contributed by @andybalaam.
|
||||
* Attempt a potential workaround for stuck notifs ([\#3384](https://github.com/matrix-org/matrix-js-sdk/pull/3384)). Fixes vector-im/element-web#25406. Contributed by @andybalaam.
|
||||
* Fix verification bug with `pendingEventOrdering: "chronological"` ([\#3382](https://github.com/matrix-org/matrix-js-sdk/pull/3382)).
|
||||
|
||||
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
|
||||
==================================================================================================
|
||||
|
||||
@@ -55,6 +55,8 @@ client.publicRooms(function (err, data) {
|
||||
See below for how to include libolm to enable end-to-end-encryption. Please check
|
||||
[the Node.js terminal app](examples/node) for a more complex example.
|
||||
|
||||
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
|
||||
|
||||
To start the client:
|
||||
|
||||
```javascript
|
||||
|
||||
+9
-1
@@ -23,11 +23,19 @@ const config: Config = {
|
||||
collectCoverageFrom: ["<rootDir>/src/**/*.{js,ts}"],
|
||||
coverageReporters: ["text-summary", "lcov"],
|
||||
testResultsProcessor: "@casualbot/jest-sonar-reporter",
|
||||
|
||||
// Always print out a summary if there are any failing tests. Normally
|
||||
// a summary is only printed if there are more than 20 test *suites*.
|
||||
reporters: [["default", { summaryThreshold: 0 }]],
|
||||
};
|
||||
|
||||
// if we're running under GHA, enable the GHA reporter
|
||||
if (env["GITHUB_ACTIONS"] !== undefined) {
|
||||
const reporters: Config["reporters"] = [["github-actions", { silent: false }], "summary"];
|
||||
const reporters: Config["reporters"] = [
|
||||
["github-actions", { silent: false }],
|
||||
// as above: always show a summary if there were any failing tests.
|
||||
["summary", { summaryThreshold: 0 }],
|
||||
];
|
||||
|
||||
// if we're running against the develop branch, also enable the slow test reporter
|
||||
if (env["GITHUB_REF"] == "refs/heads/develop") {
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "25.2.0-rc.4",
|
||||
"version": "26.1.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -55,7 +55,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
|
||||
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.10",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
@@ -101,16 +101,16 @@
|
||||
"debug": "^4.3.4",
|
||||
"docdash": "^2.0.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.39.0",
|
||||
"eslint": "8.41.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.6",
|
||||
"eslint-plugin-jsdoc": "^43.0.6",
|
||||
"eslint-plugin-jsdoc": "^45.0.0",
|
||||
"eslint-plugin-matrix-org": "^1.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"eslint-plugin-unicorn": "^46.0.0",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -125,6 +125,7 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.24.0",
|
||||
"typedoc-plugin-coverage": "^2.1.0",
|
||||
"typedoc-plugin-mdn-links": "^3.0.3",
|
||||
"typedoc-plugin-missing-exports": "^2.0.0",
|
||||
"typedoc-plugin-versions": "^0.2.3",
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
|
||||
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
/**
|
||||
* Integration tests for cross-signing functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("bootstrapCrossSigning (before initialsync completes)", () => {
|
||||
oldBackendOnly("publishes keys if none were yet published", async () => {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
{ url: "path:/_matrix/client/unstable/keys/device_signing/upload", name: "upload-keys" },
|
||||
{},
|
||||
);
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
|
||||
await makeRequest(authDict);
|
||||
};
|
||||
|
||||
// now bootstrap cross signing, and check it resolves successfully
|
||||
await aliceClient.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: uiaCallback,
|
||||
});
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
// master key is signed by the device
|
||||
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
|
||||
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
|
||||
// ssk and usk are signed by the master key
|
||||
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
|
||||
|
||||
// check the publish call
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// there should be a signature for our device, by our self-signing key.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { createClient, MatrixClient, IAuthDict, UIAuthCallback } from "../../../src";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
|
||||
// eslint-disable-next-line no-global-assign
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
|
||||
/**
|
||||
* Integration tests for cross-signing functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock the requests needed to set up cross signing
|
||||
*
|
||||
* Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request
|
||||
* Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
|
||||
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
|
||||
*/
|
||||
function mockSetupCrossSigningRequests(): void {
|
||||
// have account_data requests return an empty object
|
||||
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
|
||||
|
||||
// we expect a request to upload signatures for our device ...
|
||||
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
|
||||
|
||||
// ... and one to upload the cross-signing keys (with UIA)
|
||||
fetchMock.post(
|
||||
// legacy crypto uses /unstable/; /v3/ is correct
|
||||
{
|
||||
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
|
||||
name: "upload-keys",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cross-signing keys, publish the keys
|
||||
* Mock and bootstrap all the required steps
|
||||
*
|
||||
* @param authDict - The parameters to as the `auth` dict in the key upload request.
|
||||
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
|
||||
*/
|
||||
async function bootstrapCrossSigning(authDict: IAuthDict): Promise<void> {
|
||||
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
|
||||
await makeRequest(authDict);
|
||||
};
|
||||
|
||||
// now bootstrap cross signing, and check it resolves successfully
|
||||
await aliceClient.getCrypto()?.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: uiaCallback,
|
||||
});
|
||||
}
|
||||
|
||||
describe("bootstrapCrossSigning (before initialsync completes)", () => {
|
||||
it("publishes keys if none were yet published", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
// check the cross-signing keys upload
|
||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||
// there should be a key of each type
|
||||
// master key is signed by the device
|
||||
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
|
||||
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
|
||||
// ssk and usk are signed by the master key
|
||||
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
|
||||
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
|
||||
|
||||
// check the publish call
|
||||
expect(fetchMock.called("upload-sigs")).toBeTruthy();
|
||||
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
|
||||
const body = JSON.parse(sigsOpts!.body as string);
|
||||
// there should be a signature for our device, by our self-signing key.
|
||||
expect(body).toHaveProperty(
|
||||
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCrossSigningStatus()", () => {
|
||||
it("should return correct values without bootstrapping cross-signing", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();
|
||||
|
||||
// Expect the cross signing keys to be unavailable
|
||||
expect(crossSigningStatus).toStrictEqual({
|
||||
publicKeysOnDevice: false,
|
||||
privateKeysInSecretStorage: false,
|
||||
privateKeysCachedLocally: { masterKey: false, userSigningKey: false, selfSigningKey: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return correct values after bootstrapping cross-signing", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
// provide a UIA callback, so that the cross-signing keys are uploaded
|
||||
const authDict = { type: "test" };
|
||||
await bootstrapCrossSigning(authDict);
|
||||
|
||||
const crossSigningStatus = await aliceClient.getCrypto()!.getCrossSigningStatus();
|
||||
|
||||
// Expect the cross signing keys to be available
|
||||
expect(crossSigningStatus).toStrictEqual({
|
||||
publicKeysOnDevice: true,
|
||||
privateKeysInSecretStorage: false,
|
||||
privateKeysCachedLocally: { masterKey: true, userSigningKey: true, selfSigningKey: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCrossSigningReady()", () => {
|
||||
it("should return false if cross-signing is not bootstrapped", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
|
||||
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
|
||||
|
||||
expect(isCrossSigningReady).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return true after bootstrapping cross-signing", async () => {
|
||||
mockSetupCrossSigningRequests();
|
||||
await bootstrapCrossSigning({ type: "test" });
|
||||
|
||||
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
|
||||
|
||||
expect(isCrossSigningReady).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,11 @@ import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { MockResponse, MockResponseFunction } from "fetch-mock";
|
||||
|
||||
import type { IDeviceKeys } from "../../src/@types/crypto";
|
||||
import * as testUtils from "../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { logger } from "../../src/logger";
|
||||
import type { IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { CRYPTO_BACKENDS, InitCrypto, syncPromise } from "../../test-utils/test-utils";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
createClient,
|
||||
IClaimOTKsResult,
|
||||
@@ -43,13 +43,14 @@ import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomStateEvent,
|
||||
} from "../../src/matrix";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../test-utils/flushPromises";
|
||||
} from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
|
||||
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
|
||||
const ROOM_ID = "!room:id";
|
||||
|
||||
@@ -419,12 +420,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise<void> {
|
||||
logger.log(aliceClient.getUserId() + ": starting");
|
||||
|
||||
const homeserverUrl = aliceClient.getHomeserverUrl();
|
||||
fetchMock.get(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
|
||||
fetchMock.get(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
|
||||
fetchMock.post(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
|
||||
filter_id: "fid",
|
||||
});
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
|
||||
// we let the client do a very basic initial sync, which it needs before
|
||||
// it will upload one-time keys.
|
||||
@@ -1111,7 +1107,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
} catch (e) {
|
||||
expect((e as any).name).toEqual("UnknownDeviceError");
|
||||
expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]);
|
||||
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID"));
|
||||
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID")).toBeTruthy();
|
||||
}
|
||||
|
||||
// mark the device as known, and resend.
|
||||
@@ -16,12 +16,12 @@ limitations under the License.
|
||||
|
||||
import { Account } from "@matrix-org/olm";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { decodeRecoveryKey } from "../../src/crypto/recoverykey";
|
||||
import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { IEvent } from "../../src";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { logger } from "../../../src/logger";
|
||||
import { decodeRecoveryKey } from "../../../src/crypto/recoverykey";
|
||||
import { IKeyBackupInfo, IKeyBackupSession } from "../../../src/crypto/keybackup";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { IEvent } from "../../../src";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
|
||||
|
||||
const ROOM_ID = "!ROOM:ID";
|
||||
|
||||
@@ -26,16 +26,16 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
// load olm before the sdk if possible
|
||||
import "../olm-loader";
|
||||
import "../../olm-loader";
|
||||
|
||||
import type { Session } from "@matrix-org/olm";
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
|
||||
import { logger } from "../../src/logger";
|
||||
import * as testUtils from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../src/client";
|
||||
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../src/matrix";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import type { IDeviceKeys, IOneTimeKey } from "../../../src/@types/crypto";
|
||||
import { logger } from "../../../src/logger";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { CRYPTO_ENABLED, IClaimKeysRequest, IQueryKeysRequest, IUploadKeysRequest } from "../../../src/client";
|
||||
import { ClientEvent, IContent, ISendEventResponse, MatrixClient, MatrixEvent } from "../../../src/matrix";
|
||||
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
|
||||
|
||||
let aliTestClient: TestClient;
|
||||
const roomId = "!room:localhost";
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
import { createClient } from "../../src";
|
||||
import { createClient } from "../../../src";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { MockResponse } from "fetch-mock";
|
||||
|
||||
import { createClient, MatrixClient } from "../../../src";
|
||||
import { ShowQrCodeCallbacks, ShowSasCallbacks, Verifier, VerifierEvent } from "../../../src/crypto-api/verification";
|
||||
import { escapeRegExp } from "../../../src/utils";
|
||||
import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils";
|
||||
import { SyncResponder } from "../../test-utils/SyncResponder";
|
||||
import {
|
||||
MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64,
|
||||
SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
SIGNED_TEST_DEVICE_DATA,
|
||||
TEST_DEVICE_ID,
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
TEST_USER_ID,
|
||||
} from "../../test-utils/test-data";
|
||||
import { mockInitialApiRequests } from "../../test-utils/mockEndpoints";
|
||||
import {
|
||||
Phase,
|
||||
VerificationRequest,
|
||||
VerificationRequestEvent,
|
||||
} from "../../../src/crypto/verification/request/VerificationRequest";
|
||||
|
||||
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
|
||||
// to ensure that we don't end up with dangling timeouts.
|
||||
jest.useFakeTimers();
|
||||
|
||||
let previousCrypto: Crypto | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
// Stub out global.crypto
|
||||
previousCrypto = global["crypto"];
|
||||
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: {
|
||||
getRandomValues: function <T extends Uint8Array>(array: T): T {
|
||||
array.fill(0x12);
|
||||
return array;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// restore the original global.crypto
|
||||
afterAll(() => {
|
||||
if (previousCrypto === undefined) {
|
||||
// @ts-ignore deleting a non-optional property. It *is* optional really.
|
||||
delete global.crypto;
|
||||
} else {
|
||||
Object.defineProperty(global, "crypto", {
|
||||
value: previousCrypto,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration tests for verification functionality.
|
||||
*
|
||||
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
|
||||
* to provide the most effective integration tests possible.
|
||||
*/
|
||||
describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => {
|
||||
// oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the
|
||||
// Rust backend. Once we have full support in the rust sdk, it will go away.
|
||||
const oldBackendOnly = backend === "rust-sdk" ? test.skip : test;
|
||||
|
||||
/** the client under test */
|
||||
let aliceClient: MatrixClient;
|
||||
|
||||
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
|
||||
let syncResponder: SyncResponder;
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = false;
|
||||
|
||||
const homeserverUrl = "https://alice-server.com";
|
||||
aliceClient = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: "device_under_test",
|
||||
});
|
||||
|
||||
await initCrypto(aliceClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await aliceClient.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
syncResponder = new SyncResponder(aliceClient.getHomeserverUrl());
|
||||
mockInitialApiRequests(aliceClient.getHomeserverUrl());
|
||||
aliceClient.startClient();
|
||||
});
|
||||
|
||||
oldBackendOnly("Outgoing verification: can verify another device via SAS", async () => {
|
||||
// expect requests to download our own keys
|
||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
||||
device_keys: {
|
||||
[TEST_USER_ID]: {
|
||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
let [requestBody, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]),
|
||||
]);
|
||||
const transactionId = request.transactionId;
|
||||
expect(transactionId).toBeDefined();
|
||||
expect(request.phase).toEqual(Phase.Requested);
|
||||
expect(request.roomId).toBeUndefined();
|
||||
|
||||
let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.methods).toContain("m.sas.v1");
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready...
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
methods: ["m.sas.v1"],
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Ready);
|
||||
expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID);
|
||||
|
||||
// ... and picks a method with m.key.verification.start
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
method: "m.sas.v1",
|
||||
transaction_id: transactionId,
|
||||
hashes: ["sha256"],
|
||||
key_agreement_protocols: ["curve25519"],
|
||||
message_authentication_codes: ["hkdf-hmac-sha256.v2"],
|
||||
short_authentication_string: ["emoji"],
|
||||
},
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.sas.v1");
|
||||
|
||||
// there should now be a verifier
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||
|
||||
// start off the verification process: alice will send an `accept`
|
||||
const verificationPromise = verifier.verify();
|
||||
// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
|
||||
jest.advanceTimersByTime(10);
|
||||
|
||||
requestBody = await expectSendToDeviceMessage("m.key.verification.accept");
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519");
|
||||
expect(toDeviceMessage.short_authentication_string).toEqual(["emoji"]);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
|
||||
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
|
||||
const olmSAS = new global.Olm.SAS();
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.key",
|
||||
content: {
|
||||
transaction_id: transactionId,
|
||||
key: olmSAS.get_pubkey(),
|
||||
},
|
||||
});
|
||||
|
||||
// alice responds with a 'key' ...
|
||||
requestBody = await expectSendToDeviceMessage("m.key.verification.key");
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
const aliceDevicePubKeyBase64 = toDeviceMessage.key;
|
||||
olmSAS.set_their_key(aliceDevicePubKeyBase64);
|
||||
|
||||
// ... and the client is notified to show the emoji
|
||||
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
|
||||
verifier.once(VerifierEvent.ShowSas, resolve);
|
||||
});
|
||||
|
||||
// `getShowSasCallbacks` is an alternative way to get the callbacks
|
||||
expect(verifier.getShowSasCallbacks()).toBe(showSas);
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
// user confirms that the emoji match, and alice sends a 'mac'
|
||||
[requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]);
|
||||
toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// the dummy device also confirms that the emoji match, and sends a mac
|
||||
const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`;
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.mac",
|
||||
content: {
|
||||
keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`),
|
||||
transaction_id: transactionId,
|
||||
mac: {
|
||||
[`ed25519:${TEST_DEVICE_ID}`]: calculateMAC(
|
||||
olmSAS,
|
||||
TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64,
|
||||
`${macInfoBase}ed25519:${TEST_DEVICE_ID}`,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// that should satisfy Alice, who should reply with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(Phase.Done);
|
||||
|
||||
// we're done with the temporary keypair
|
||||
olmSAS.free();
|
||||
});
|
||||
|
||||
oldBackendOnly(
|
||||
"Outgoing verification: can verify another device via QR code with an untrusted cross-signing key",
|
||||
async () => {
|
||||
// expect requests to download our own keys
|
||||
fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), {
|
||||
device_keys: {
|
||||
[TEST_USER_ID]: {
|
||||
[TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA,
|
||||
},
|
||||
},
|
||||
...SIGNED_CROSS_SIGNING_KEYS_DATA,
|
||||
});
|
||||
|
||||
// QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now.
|
||||
//
|
||||
// Completing the initial sync will make the device list download outdated device lists (of which our own
|
||||
// user will be one).
|
||||
syncResponder.sendOrQueueSyncResponse({});
|
||||
// DeviceList has a sleep(5) which we need to make happen
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy();
|
||||
|
||||
// have alice initiate a verification. She should send a m.key.verification.request
|
||||
const [requestBody, request] = await Promise.all([
|
||||
expectSendToDeviceMessage("m.key.verification.request"),
|
||||
aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]),
|
||||
]);
|
||||
const transactionId = request.transactionId;
|
||||
|
||||
const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
|
||||
expect(toDeviceMessage.methods).toContain("m.qr_code.show.v1");
|
||||
expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1");
|
||||
expect(toDeviceMessage.methods).toContain("m.reciprocate.v1");
|
||||
expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId);
|
||||
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
|
||||
|
||||
// The dummy device replies with an m.key.verification.ready, with an indication we can scan the QR code
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.ready",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
methods: ["m.qr_code.scan.v1"],
|
||||
transaction_id: transactionId,
|
||||
},
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Ready);
|
||||
|
||||
// we should now have QR data we can display
|
||||
const qrCodeBuffer = request.getQRCodeBytes()!;
|
||||
expect(qrCodeBuffer).toBeTruthy();
|
||||
|
||||
// https://spec.matrix.org/v1.7/client-server-api/#qr-code-format
|
||||
expect(qrCodeBuffer.subarray(0, 6).toString("latin1")).toEqual("MATRIX");
|
||||
expect(qrCodeBuffer.readUint8(6)).toEqual(0x02); // version
|
||||
expect(qrCodeBuffer.readUint8(7)).toEqual(0x02); // mode
|
||||
const txnIdLen = qrCodeBuffer.readUint16BE(8);
|
||||
expect(qrCodeBuffer.subarray(10, 10 + txnIdLen).toString("utf-8")).toEqual(transactionId);
|
||||
// Alice's device's public key comes next, but we have nothing to do with it here.
|
||||
// const aliceDevicePubKey = qrCodeBuffer.subarray(10 + txnIdLen, 32 + 10 + txnIdLen);
|
||||
expect(qrCodeBuffer.subarray(42 + txnIdLen, 32 + 42 + txnIdLen)).toEqual(
|
||||
Buffer.from(MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64, "base64"),
|
||||
);
|
||||
const sharedSecret = qrCodeBuffer.subarray(74 + txnIdLen);
|
||||
|
||||
// the dummy device "scans" the displayed QR code and acknowledges it with a "m.key.verification.start"
|
||||
returnToDeviceMessageFromSync({
|
||||
type: "m.key.verification.start",
|
||||
content: {
|
||||
from_device: TEST_DEVICE_ID,
|
||||
method: "m.reciprocate.v1",
|
||||
transaction_id: transactionId,
|
||||
secret: encodeUnpaddedBase64(sharedSecret),
|
||||
},
|
||||
});
|
||||
await waitForVerificationRequestChanged(request);
|
||||
expect(request.phase).toEqual(Phase.Started);
|
||||
expect(request.chosenMethod).toEqual("m.reciprocate.v1");
|
||||
|
||||
// there should now be a verifier
|
||||
const verifier: Verifier = request.verifier!;
|
||||
expect(verifier).toBeDefined();
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull();
|
||||
|
||||
// ... which we call .verify on, which emits a ShowReciprocateQr event
|
||||
const verificationPromise = verifier.verify();
|
||||
const reciprocateQRCodeCallbacks = await new Promise<ShowQrCodeCallbacks>((resolve) => {
|
||||
verifier.once(VerifierEvent.ShowReciprocateQr, resolve);
|
||||
});
|
||||
|
||||
// getReciprocateQrCodeCallbacks() is an alternative way to get the callbacks
|
||||
expect(verifier.getReciprocateQrCodeCallbacks()).toBe(reciprocateQRCodeCallbacks);
|
||||
expect(verifier.getShowSasCallbacks()).toBeNull();
|
||||
|
||||
// Alice confirms she is happy
|
||||
reciprocateQRCodeCallbacks.confirm();
|
||||
|
||||
// that should satisfy Alice, who should reply with a 'done'
|
||||
await expectSendToDeviceMessage("m.key.verification.done");
|
||||
|
||||
// ... and the whole thing should be done!
|
||||
await verificationPromise;
|
||||
expect(request.phase).toEqual(Phase.Done);
|
||||
},
|
||||
);
|
||||
|
||||
function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void {
|
||||
ev.sender ??= TEST_USER_ID;
|
||||
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Wait for the client under test to send a to-device message of the given type.
|
||||
*
|
||||
* @param msgtype - type of to-device message we expect
|
||||
* @returns A Promise which resolves with the body of the HTTP request
|
||||
*/
|
||||
function expectSendToDeviceMessage(msgtype: string): Promise<{ messages: any }> {
|
||||
return new Promise((resolve) => {
|
||||
fetchMock.putOnce(
|
||||
new RegExp(`/_matrix/client/(r0|v3)/sendToDevice/${escapeRegExp(msgtype)}`),
|
||||
(url: string, opts: RequestInit): MockResponse => {
|
||||
resolve(JSON.parse(opts.body as string));
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** wait for the verification request to emit a 'Change' event */
|
||||
function waitForVerificationRequestChanged(request: VerificationRequest): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
request.once(VerificationRequestEvent.Change, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/** Perform a MAC calculation on the given data
|
||||
*
|
||||
* Does an HKDR and HMAC as defined by the matrix spec (https://spec.matrix.org/v1.7/client-server-api/#mac-calculation,
|
||||
* as amended by https://github.com/matrix-org/matrix-spec/issues/1553).
|
||||
*
|
||||
* @param olmSAS
|
||||
* @param input
|
||||
* @param info
|
||||
*/
|
||||
function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string {
|
||||
const mac = olmSAS.calculate_mac_fixed_base64(input, info);
|
||||
//console.info(`Test MAC: input:'${input}, info: '${info}' -> '${mac}`);
|
||||
return mac;
|
||||
}
|
||||
|
||||
function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
|
||||
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
|
||||
}
|
||||
@@ -1142,7 +1142,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
@@ -1156,7 +1156,7 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
room.addLiveEvents([THREAD_REPLY3]);
|
||||
await room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
@@ -1248,7 +1248,7 @@ describe("MatrixClient event timelines", function () {
|
||||
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
// Assume we're seeing the reply while loading backlog
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
httpBackend
|
||||
.when(
|
||||
"GET",
|
||||
@@ -1267,13 +1267,14 @@ describe("MatrixClient event timelines", function () {
|
||||
});
|
||||
await flushHttp(prom);
|
||||
// but while loading the metadata, a new reply has arrived
|
||||
room.addLiveEvents([THREAD_REPLY3]);
|
||||
await room.addLiveEvents([THREAD_REPLY3]);
|
||||
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
|
||||
// then the events should still be all in the right order
|
||||
expect(thread.events.map((it) => it.getId())).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD_REPLY.event_id,
|
||||
THREAD_REPLY2.getId(),
|
||||
THREAD_ROOT_REACTION.getId(),
|
||||
THREAD_REPLY3.getId(),
|
||||
]);
|
||||
});
|
||||
@@ -1322,7 +1323,7 @@ describe("MatrixClient event timelines", function () {
|
||||
request.respond(200, function () {
|
||||
return {
|
||||
original_event: root,
|
||||
chunk: [replies],
|
||||
chunk: replies,
|
||||
// no next batch as this is the oldest end of the timeline
|
||||
};
|
||||
});
|
||||
@@ -1452,7 +1453,7 @@ describe("MatrixClient event timelines", function () {
|
||||
expect(room.getPendingEvents()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should handle thread updates by reordering the thread list", async () => {
|
||||
it("should handle new thread replies by reordering the thread list", async () => {
|
||||
// Test data for a second thread
|
||||
const THREAD2_ROOT = utils.mkEvent({
|
||||
room: roomId,
|
||||
@@ -1479,7 +1480,7 @@ describe("MatrixClient event timelines", function () {
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply",
|
||||
"body": "thread2 reply",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
@@ -1499,7 +1500,7 @@ describe("MatrixClient event timelines", function () {
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread reply",
|
||||
"body": "thread reply2",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
@@ -1571,8 +1572,9 @@ describe("MatrixClient event timelines", function () {
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
room.addLiveEvents([THREAD_REPLY2]);
|
||||
await room.addLiveEvents([THREAD_REPLY2]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(2);
|
||||
@@ -1582,6 +1584,134 @@ describe("MatrixClient event timelines", function () {
|
||||
THREAD_ROOT.event_id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not reorder the thread list on other thread updates", async () => {
|
||||
// Test data for a second thread
|
||||
const THREAD2_ROOT = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "thread root",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
unsigned: {
|
||||
"m.relations": {
|
||||
"io.element.thread": {
|
||||
//"latest_event": undefined,
|
||||
count: 1,
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
event: false,
|
||||
});
|
||||
|
||||
const THREAD2_REPLY = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
"body": "thread2 reply",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
// We can't use the const here because we change server support mode for test
|
||||
rel_type: "io.element.thread",
|
||||
event_id: THREAD_ROOT.event_id,
|
||||
},
|
||||
},
|
||||
event: false,
|
||||
});
|
||||
|
||||
// @ts-ignore we know this is a defined path for THREAD ROOT
|
||||
THREAD2_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD2_REPLY;
|
||||
|
||||
const THREAD_REPLY_REACTION = utils.mkEvent({
|
||||
room: roomId,
|
||||
user: userId,
|
||||
type: "m.reaction",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: THREAD_REPLY.event_id,
|
||||
key: "🪿",
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
});
|
||||
THREAD_REPLY_REACTION.localTimestamp += 1000;
|
||||
|
||||
// Modified thread root event containing latest thread reply in its unsigned
|
||||
const THREAD_ROOT_UPDATED = {
|
||||
...THREAD_ROOT,
|
||||
unsigned: {
|
||||
...THREAD_ROOT.unsigned,
|
||||
"m.relations": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"],
|
||||
"io.element.thread": {
|
||||
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
|
||||
count: 2,
|
||||
latest_event: THREAD_REPLY,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Response with test data for the thread list request
|
||||
const threadsResponse = {
|
||||
chunk: [THREAD2_ROOT, THREAD_ROOT],
|
||||
state: [],
|
||||
next_batch: RANDOM_TOKEN as string | null,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
client.clientOpts.threadSupport = true;
|
||||
Thread.setServerSideSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideListSupport(FeatureSupport.Stable);
|
||||
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
|
||||
|
||||
await client.stopClient(); // we don't need the client to be syncing at this time
|
||||
const room = client.getRoom(roomId)!;
|
||||
|
||||
// Set up room threads
|
||||
const timelineSets = await room!.createThreadsTimelineSets();
|
||||
expect(timelineSets).not.toBeNull();
|
||||
respondToThreads(threadsResponse);
|
||||
respondToThreads(threadsResponse);
|
||||
respondToEvent(THREAD_ROOT);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
respondToThread(THREAD_ROOT, [THREAD_REPLY]);
|
||||
respondToThread(THREAD2_ROOT, [THREAD2_REPLY]);
|
||||
await flushHttp(room.fetchRoomThreads());
|
||||
const threadIds = room.getThreads().map((thread) => thread.id);
|
||||
expect(threadIds).toContain(THREAD_ROOT.event_id);
|
||||
expect(threadIds).toContain(THREAD2_ROOT.event_id);
|
||||
const [allThreads] = timelineSets!;
|
||||
const timeline = allThreads.getLiveTimeline()!;
|
||||
// Test threads are in chronological order
|
||||
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD2_ROOT.event_id,
|
||||
]);
|
||||
|
||||
// Test adding a second event to the first thread
|
||||
const thread = room.getThread(THREAD_ROOT.event_id!)!;
|
||||
thread.initialEventsFetched = true;
|
||||
const prom = emitPromise(room, ThreadEvent.Update);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD_ROOT_UPDATED);
|
||||
respondToEvent(THREAD2_ROOT);
|
||||
await room.addLiveEvents([THREAD_REPLY_REACTION]);
|
||||
await httpBackend.flushAllExpected();
|
||||
await prom;
|
||||
expect(thread.length).toBe(2);
|
||||
// Test thread order is unchanged
|
||||
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
|
||||
THREAD_ROOT.event_id,
|
||||
THREAD2_ROOT.event_id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without server compatibility", function () {
|
||||
@@ -1937,11 +2067,6 @@ describe("MatrixClient event timelines", function () {
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
return THREAD_ROOT;
|
||||
});
|
||||
httpBackend
|
||||
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
|
||||
.respond(200, function () {
|
||||
|
||||
@@ -36,11 +36,15 @@ import {
|
||||
NotificationCountType,
|
||||
IEphemeral,
|
||||
Room,
|
||||
IndexedDBStore,
|
||||
RelationType,
|
||||
} from "../../src";
|
||||
import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
|
||||
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
|
||||
|
||||
describe("MatrixClient syncing", () => {
|
||||
const selfUserId = "@alice:localhost";
|
||||
@@ -1867,4 +1871,124 @@ describe("MatrixClient syncing (IndexedDB version)", () => {
|
||||
idbClient.stopClient();
|
||||
idbHttpBackend.stop();
|
||||
});
|
||||
|
||||
it("should query server for which thread a 2nd order relation belongs to and stash in sync accumulator", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
async function startClient(client: MatrixClient): Promise<void> {
|
||||
await Promise.all([
|
||||
idbClient.startClient({
|
||||
// Without this all events just go into the main timeline
|
||||
threadSupport: true,
|
||||
}),
|
||||
idbHttpBackend.flushAllExpected(),
|
||||
emitPromise(idbClient, ClientEvent.Room),
|
||||
]);
|
||||
}
|
||||
|
||||
function assertEventsExpected(client: MatrixClient): void {
|
||||
const room = client.getRoom(roomId);
|
||||
const mainTimelineEvents = room!.getLiveTimeline().getEvents();
|
||||
expect(mainTimelineEvents).toHaveLength(1);
|
||||
expect(mainTimelineEvents[0].getContent().body).toEqual("Test");
|
||||
|
||||
const thread = room!.getThread("$someThreadId")!;
|
||||
expect(thread.replayEvents).toHaveLength(1);
|
||||
expect(thread.replayEvents![0].getRelation()!.key).toEqual("🪿");
|
||||
}
|
||||
|
||||
let idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
|
||||
store: new IndexedDBStore({
|
||||
indexedDB: global.indexedDB,
|
||||
dbName: "test",
|
||||
}),
|
||||
});
|
||||
let idbHttpBackend = idbTestClient.httpBackend;
|
||||
let idbClient = idbTestClient.client;
|
||||
await idbClient.store.startup();
|
||||
|
||||
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
|
||||
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
|
||||
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
|
||||
const syncRoomSection = {
|
||||
join: {
|
||||
[roomId]: {
|
||||
timeline: {
|
||||
prev_batch: "foo",
|
||||
events: [
|
||||
mkMessage({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
msg: "Test",
|
||||
}),
|
||||
mkEvent({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: "$someUnknownEvent",
|
||||
key: "🪿",
|
||||
},
|
||||
},
|
||||
type: "m.reaction",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
idbHttpBackend.when("GET", "/sync").respond(200, {
|
||||
...syncData,
|
||||
rooms: syncRoomSection,
|
||||
});
|
||||
idbHttpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/event/%24someUnknownEvent`).respond(
|
||||
200,
|
||||
mkEvent({
|
||||
room: roomId,
|
||||
user: selfUserId,
|
||||
content: {
|
||||
"body": "Thread response",
|
||||
"m.relates_to": {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
event_id: "$someThreadId",
|
||||
},
|
||||
},
|
||||
type: "m.room.message",
|
||||
}),
|
||||
);
|
||||
|
||||
await startClient(idbClient);
|
||||
assertEventsExpected(idbClient);
|
||||
|
||||
idbHttpBackend.verifyNoOutstandingExpectation();
|
||||
// Force sync accumulator to persist, reset client, assert it doesn't re-fetch event on next start-up
|
||||
await idbClient.store.save(true);
|
||||
await idbClient.stopClient();
|
||||
await idbClient.store.destroy();
|
||||
await idbHttpBackend.stop();
|
||||
|
||||
idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
|
||||
store: new IndexedDBStore({
|
||||
indexedDB: global.indexedDB,
|
||||
dbName: "test",
|
||||
}),
|
||||
});
|
||||
idbHttpBackend = idbTestClient.httpBackend;
|
||||
idbClient = idbTestClient.client;
|
||||
await idbClient.store.startup();
|
||||
|
||||
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
|
||||
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
|
||||
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
|
||||
idbHttpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
await startClient(idbClient);
|
||||
assertEventsExpected(idbClient);
|
||||
|
||||
idbHttpBackend.verifyNoOutstandingExpectation();
|
||||
await idbClient.stopClient();
|
||||
await idbHttpBackend.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("MatrixClient syncing", () => {
|
||||
|
||||
const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] });
|
||||
const threadReply = thread.events.at(-1)!;
|
||||
room.addLiveEvents([thread.rootEvent]);
|
||||
await room.addLiveEvents([thread.rootEvent]);
|
||||
|
||||
// Initialize read receipt datastructure before testing the reaction
|
||||
room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { fail } from "assert";
|
||||
|
||||
import { SlidingSync, SlidingSyncEvent, MSC3575RoomData, SlidingSyncState, Extension } from "../../src/sliding-sync";
|
||||
import { TestClient } from "../TestClient";
|
||||
import { IRoomEvent, IStateEvent } from "../../src/sync-accumulator";
|
||||
import { IRoomEvent, IStateEvent } from "../../src";
|
||||
import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
@@ -39,9 +39,10 @@ import {
|
||||
} from "../../src";
|
||||
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
|
||||
import { SyncApiOptions, SyncState } from "../../src/sync";
|
||||
import { IStoredClientOpts } from "../../src/client";
|
||||
import { IStoredClientOpts } from "../../src";
|
||||
import { logger } from "../../src/logger";
|
||||
import { emitPromise } from "../test-utils/test-utils";
|
||||
import { defer } from "../../src/utils";
|
||||
|
||||
describe("SlidingSyncSdk", () => {
|
||||
let client: MatrixClient | undefined;
|
||||
@@ -301,67 +302,57 @@ describe("SlidingSyncSdk", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("can be created with required_state and timeline", () => {
|
||||
it("can be created with required_state and timeline", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomA].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomA].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
||||
});
|
||||
|
||||
it("can be created with timeline only", () => {
|
||||
it("can be created with timeline only", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomB].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomB].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
||||
});
|
||||
|
||||
it("can be created with a highlight_count", () => {
|
||||
it("can be created with a highlight_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomC);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
||||
data[roomC].highlight_count,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be created with a notification_count", () => {
|
||||
it("can be created with a notification_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomD);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
||||
data[roomD].notification_count,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be created with an invited/joined_count", () => {
|
||||
it("can be created with an invited/joined_count", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomG);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
||||
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
||||
expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
||||
});
|
||||
|
||||
it("can be created with live events", () => {
|
||||
let seenLiveEvent = false;
|
||||
it("can be created with live events", async () => {
|
||||
const seenLiveEventDeferred = defer<boolean>();
|
||||
const listener = (
|
||||
ev: MatrixEvent,
|
||||
room?: Room,
|
||||
@@ -371,43 +362,37 @@ describe("SlidingSyncSdk", () => {
|
||||
) => {
|
||||
if (timelineData?.liveEvent) {
|
||||
assertTimelineEvents([ev], data[roomH].timeline.slice(-1));
|
||||
seenLiveEvent = true;
|
||||
seenLiveEventDeferred.resolve(true);
|
||||
}
|
||||
};
|
||||
client!.on(RoomEvent.Timeline, listener);
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
client!.off(RoomEvent.Timeline, listener);
|
||||
const gotRoom = client!.getRoom(roomH);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomH].name);
|
||||
expect(gotRoom.getMyMembership()).toEqual("join");
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomH].name);
|
||||
expect(gotRoom!.getMyMembership()).toEqual("join");
|
||||
// check the entire timeline is correct
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline);
|
||||
expect(seenLiveEvent).toBe(true);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline);
|
||||
await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("can be created with invite_state", () => {
|
||||
it("can be created with invite_state", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomE);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getMyMembership()).toEqual("invite");
|
||||
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.getMyMembership()).toEqual("invite");
|
||||
expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
||||
});
|
||||
|
||||
it("uses the 'name' field to caluclate the room name", () => {
|
||||
it("uses the 'name' field to caluclate the room name", async () => {
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const gotRoom = client!.getRoom(roomF);
|
||||
expect(gotRoom).toBeDefined();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.name).toEqual(data[roomF].name);
|
||||
expect(gotRoom).toBeTruthy();
|
||||
expect(gotRoom!.name).toEqual(data[roomF].name);
|
||||
});
|
||||
|
||||
describe("updating", () => {
|
||||
@@ -419,33 +404,33 @@ describe("SlidingSyncSdk", () => {
|
||||
name: data[roomA].name,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
const newTimeline = data[roomA].timeline;
|
||||
newTimeline.push(newEvent);
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
||||
});
|
||||
|
||||
it("can update with a new required_state event", async () => {
|
||||
let gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
|
||||
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default
|
||||
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, {
|
||||
required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")],
|
||||
timeline: [],
|
||||
name: data[roomB].name,
|
||||
});
|
||||
gotRoom = client!.getRoom(roomB);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
|
||||
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted);
|
||||
});
|
||||
|
||||
it("can update with a new highlight_count", async () => {
|
||||
@@ -456,11 +441,11 @@ describe("SlidingSyncSdk", () => {
|
||||
highlight_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomC);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
||||
});
|
||||
|
||||
it("can update with a new notification_count", async () => {
|
||||
@@ -471,11 +456,11 @@ describe("SlidingSyncSdk", () => {
|
||||
notification_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomD);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
||||
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
||||
});
|
||||
|
||||
it("can update with a new joined_count", () => {
|
||||
@@ -486,11 +471,11 @@ describe("SlidingSyncSdk", () => {
|
||||
joined_count: 1,
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomG);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
|
||||
expect(gotRoom!.getJoinedMemberCount()).toEqual(1);
|
||||
});
|
||||
|
||||
// Regression test for a bug which caused the timeline entries to be out-of-order
|
||||
@@ -512,7 +497,7 @@ describe("SlidingSyncSdk", () => {
|
||||
initial: true, // e.g requested via room subscription
|
||||
});
|
||||
const gotRoom = client!.getRoom(roomA);
|
||||
expect(gotRoom).toBeDefined();
|
||||
expect(gotRoom).toBeTruthy();
|
||||
if (gotRoom == null) {
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +515,7 @@ describe("SlidingSyncSdk", () => {
|
||||
);
|
||||
|
||||
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
|
||||
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline);
|
||||
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -626,9 +611,9 @@ describe("SlidingSyncSdk", () => {
|
||||
await httpBackend!.flush("/profile", 1, 1000);
|
||||
await emitPromise(client!, RoomMemberEvent.Name);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const inviteeMember = room.getMember(invitee)!;
|
||||
expect(inviteeMember).toBeDefined();
|
||||
expect(inviteeMember).toBeTruthy();
|
||||
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
|
||||
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
|
||||
});
|
||||
@@ -723,7 +708,7 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
});
|
||||
globalData = client!.getAccountData(globalType)!;
|
||||
expect(globalData).toBeDefined();
|
||||
expect(globalData).toBeTruthy();
|
||||
expect(globalData.getContent()).toEqual(globalContent);
|
||||
});
|
||||
|
||||
@@ -744,6 +729,7 @@ describe("SlidingSyncSdk", () => {
|
||||
foo: "bar",
|
||||
};
|
||||
const roomType = "test";
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
[roomId]: [
|
||||
@@ -755,9 +741,9 @@ describe("SlidingSyncSdk", () => {
|
||||
},
|
||||
});
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const event = room.getAccountData(roomType)!;
|
||||
expect(event).toBeDefined();
|
||||
expect(event).toBeTruthy();
|
||||
expect(event.getContent()).toEqual(roomContent);
|
||||
});
|
||||
|
||||
@@ -943,8 +929,9 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
initial: true,
|
||||
});
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
@@ -984,7 +971,7 @@ describe("SlidingSyncSdk", () => {
|
||||
initial: true,
|
||||
});
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
||||
ext.onResponse({
|
||||
rooms: {
|
||||
@@ -1077,12 +1064,13 @@ describe("SlidingSyncSdk", () => {
|
||||
],
|
||||
initial: true,
|
||||
});
|
||||
await emitPromise(client!, ClientEvent.Room);
|
||||
const room = client!.getRoom(roomId)!;
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
expect(room.getReadReceiptForUserId(alice, true)).toBeNull();
|
||||
ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567));
|
||||
const receipt = room.getReadReceiptForUserId(alice);
|
||||
expect(receipt).toBeDefined();
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(lastEvent.event_id);
|
||||
expect(receipt?.data.ts).toEqual(1234567);
|
||||
expect(receipt?.data.thread_id).toBeFalsy();
|
||||
|
||||
+1
-1
@@ -23,5 +23,5 @@ try {
|
||||
global.Olm = require("@matrix-org/olm");
|
||||
logger.log("loaded libolm");
|
||||
} catch (e) {
|
||||
logger.warn("unable to run crypto tests: libolm not available");
|
||||
logger.warn("unable to run crypto tests: libolm not available", e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
/**
|
||||
* Mock out the endpoints that the js-sdk calls when we call `MatrixClient.start()`.
|
||||
*
|
||||
* @param homeserverUrl - the homeserver url for the client under test
|
||||
*/
|
||||
export function mockInitialApiRequests(homeserverUrl: string) {
|
||||
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["r0.5.0"] });
|
||||
fetchMock.getOnce(new URL("/_matrix/client/r0/pushrules/", homeserverUrl).toString(), {});
|
||||
fetchMock.postOnce(new URL("/_matrix/client/r0/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
|
||||
filter_id: "fid",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/env
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
#!/bin/env python
|
||||
#
|
||||
# Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This file is a Python script to generate test data for crypto tests.
|
||||
|
||||
To run it:
|
||||
|
||||
python -m venv env
|
||||
./env/bin/pip install cryptography canonicaljson
|
||||
./env/bin/python generate-test-data.py > index.ts
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from canonicaljson import encode_canonical_json
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
# input data
|
||||
TEST_USER_ID = "@alice:localhost"
|
||||
TEST_DEVICE_ID = "test_device"
|
||||
# any 32-byte string can be an ed25519 private key.
|
||||
TEST_DEVICE_PRIVATE_KEY_BYTES = b"deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"doyouspeakwhaaaaaaaaaaaaaaaaaale"
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"useruseruseruseruseruseruseruser"
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES = b"selfselfselfselfselfselfselfself"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
TEST_DEVICE_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_public_key = encode_base64(
|
||||
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
device_data = {
|
||||
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
|
||||
"device_id": TEST_DEVICE_ID,
|
||||
"keys": {
|
||||
f"curve25519:{TEST_DEVICE_ID}": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
f"ed25519:{TEST_DEVICE_ID}": b64_public_key,
|
||||
},
|
||||
"signatures": {TEST_USER_ID: {}},
|
||||
"user_id": TEST_USER_ID,
|
||||
}
|
||||
|
||||
device_data["signatures"][TEST_USER_ID][f"ed25519:{TEST_DEVICE_ID}"] = sign_json(
|
||||
device_data, private_key
|
||||
)
|
||||
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
|
||||
print(
|
||||
f"""\
|
||||
/* Test data for cryptography tests
|
||||
*
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import {{ IDeviceKeys }} from "../../../src/@types/crypto";
|
||||
import {{ IDownloadKeyResult }} from "../../../src";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
export const TEST_USER_ID = "{TEST_USER_ID}";
|
||||
export const TEST_DEVICE_ID = "{TEST_DEVICE_ID}";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "{b64_public_key}";
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {json.dumps(device_data, indent=4)};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "{b64_master_public_key}";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
json.dumps(build_cross_signing_keys_data(), indent=4)
|
||||
};
|
||||
""",
|
||||
end="",
|
||||
)
|
||||
|
||||
|
||||
def build_cross_signing_keys_data() -> dict:
|
||||
"""Build the signed cross-signing-keys data for return from /keys/query"""
|
||||
master_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_master_public_key = encode_base64(
|
||||
master_private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
)
|
||||
self_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
SELF_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_self_signing_public_key = encode_base64(
|
||||
self_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
user_signing_private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
USER_CROSS_SIGNING_PRIVATE_KEY_BYTES
|
||||
)
|
||||
b64_user_signing_public_key = encode_base64(
|
||||
user_signing_private_key.public_key().public_bytes(
|
||||
Encoding.Raw, PublicFormat.Raw
|
||||
)
|
||||
)
|
||||
# create without signatures initially
|
||||
cross_signing_keys_data = {
|
||||
"master_keys": {
|
||||
TEST_USER_ID: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_master_public_key}": b64_master_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"usage": ["master"],
|
||||
}
|
||||
},
|
||||
"self_signing_keys": {
|
||||
TEST_USER_ID: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_self_signing_public_key}": b64_self_signing_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"usage": ["self_signing"],
|
||||
},
|
||||
},
|
||||
"user_signing_keys": {
|
||||
TEST_USER_ID: {
|
||||
"keys": {
|
||||
f"ed25519:{b64_user_signing_public_key}": b64_user_signing_public_key,
|
||||
},
|
||||
"user_id": TEST_USER_ID,
|
||||
"usage": ["user_signing"],
|
||||
},
|
||||
},
|
||||
}
|
||||
# sign the sub-keys with the master
|
||||
for k in ["self_signing_keys", "user_signing_keys"]:
|
||||
to_sign = cross_signing_keys_data[k][TEST_USER_ID]
|
||||
sig = sign_json(to_sign, master_private_key)
|
||||
to_sign["signatures"] = {
|
||||
TEST_USER_ID: {f"ed25519:{b64_master_public_key}": sig}
|
||||
}
|
||||
|
||||
return cross_signing_keys_data
|
||||
|
||||
|
||||
def encode_base64(input_bytes: bytes) -> str:
|
||||
"""Encode with unpadded base64"""
|
||||
output_bytes = base64.b64encode(input_bytes)
|
||||
output_string = output_bytes.decode("ascii")
|
||||
return output_string.rstrip("=")
|
||||
|
||||
|
||||
def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
|
||||
"""
|
||||
Sign the given json object
|
||||
|
||||
Returns the base64-encoded signature of signing `input` following the Matrix
|
||||
JSON signature algorithm [1]
|
||||
|
||||
[1]: https://spec.matrix.org/v1.7/appendices/#signing-details
|
||||
"""
|
||||
signatures = json_object.pop("signatures", {})
|
||||
unsigned = json_object.pop("unsigned", None)
|
||||
|
||||
signature = private_key.sign(encode_canonical_json(json_object))
|
||||
signature_base64 = encode_base64(signature)
|
||||
|
||||
json_object["signatures"] = signatures
|
||||
if unsigned is not None:
|
||||
json_object["unsigned"] = unsigned
|
||||
|
||||
return signature_base64
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
/* Test data for cryptography tests
|
||||
*
|
||||
* Do not edit by hand! This file is generated by `./generate-test-data.py`
|
||||
*/
|
||||
|
||||
import { IDeviceKeys } from "../../../src/@types/crypto";
|
||||
import { IDownloadKeyResult } from "../../../src";
|
||||
|
||||
/* eslint-disable comma-dangle */
|
||||
|
||||
export const TEST_USER_ID = "@alice:localhost";
|
||||
export const TEST_DEVICE_ID = "test_device";
|
||||
|
||||
/** The base64-encoded public ed25519 key for this device */
|
||||
export const TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64 = "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU";
|
||||
|
||||
/** Signed device data, suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_TEST_DEVICE_DATA: IDeviceKeys = {
|
||||
"algorithms": [
|
||||
"m.olm.v1.curve25519-aes-sha2",
|
||||
"m.megolm.v1.aes-sha2"
|
||||
],
|
||||
"device_id": "test_device",
|
||||
"keys": {
|
||||
"curve25519:test_device": "F4uCNNlcbRvc7CfBz95ZGWBvY1ALniG1J8+6rhVoKS0",
|
||||
"ed25519:test_device": "YI/7vbGVLpGdYtuceQR8MSsKB/QjgfMXM1xqnn+0NWU"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:test_device": "LmQC/yAUZJmkxZ+3L0nEwvtVWOzjqQqADWBhk+C47SPaFYHeV+E291mgXaSCJVeGltX+HC49Aw7nb6ga7sw0Aw"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** base64-encoded public master cross-signing key */
|
||||
export const MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64 = "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY";
|
||||
|
||||
/** Signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
|
||||
export const SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
|
||||
"master_keys": {
|
||||
"@alice:localhost": {
|
||||
"keys": {
|
||||
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"usage": [
|
||||
"master"
|
||||
]
|
||||
}
|
||||
},
|
||||
"self_signing_keys": {
|
||||
"@alice:localhost": {
|
||||
"keys": {
|
||||
"ed25519:aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY": "aU2+2CyXQTCuDcmWW0EL2bhJ6PdjFW2LbAsbHqf02AY"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"usage": [
|
||||
"self_signing"
|
||||
],
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "XfhYEhZmOs8BJdb3viatILBZ/bElsHXEW28V4tIaY5CxrBR0YOym3yZHWmRmypXessHZAKOhZn3yBMXzdajyCw"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_signing_keys": {
|
||||
"@alice:localhost": {
|
||||
"keys": {
|
||||
"ed25519:g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY": "g5TC/zjQXyZYuDLZv7a41z5fFVrXpYPypG//AFQj8hY"
|
||||
},
|
||||
"user_id": "@alice:localhost",
|
||||
"usage": [
|
||||
"user_signing"
|
||||
],
|
||||
"signatures": {
|
||||
"@alice:localhost": {
|
||||
"ed25519:J+5An10v1vzZpAXTYFokD1/PEVccFnLC61EfRXit0UY": "6AkD1XM2H0/ebgP9oBdMKNeft7uxsrb0XN1CsjjHgeZCvCTMmv3BHlLiT/Hzy4fe8H+S1tr484dcXN/PIdnfDA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import "../olm-loader";
|
||||
|
||||
import { logger } from "../../src/logger";
|
||||
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType, RelationType } from "../../src";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
@@ -258,6 +258,9 @@ export interface IMessageOpts {
|
||||
* @param opts.user - The user ID for the event.
|
||||
* @param opts.msg - Optional. The content.body for the event.
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param opts.relatesTo - An IEventRelation relating this to another event.
|
||||
* @param opts.ts - The timestamp of the event.
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
|
||||
* @returns The event
|
||||
*/
|
||||
@@ -297,6 +300,7 @@ interface IReplyMessageOpts extends IMessageOpts {
|
||||
* @param opts.room - The room ID for the event.
|
||||
* @param opts.user - The user ID for the event.
|
||||
* @param opts.msg - Optional. The content.body for the event.
|
||||
* @param opts.ts - The timestamp of the event.
|
||||
* @param opts.replyToMessage - The replied message
|
||||
* @param opts.event - True to make a MatrixEvent.
|
||||
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
|
||||
@@ -330,6 +334,73 @@ export function mkReplyMessage(
|
||||
return mkEvent(eventOpts, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reaction event.
|
||||
*
|
||||
* @param target - the event we are reacting to.
|
||||
* @param client - the MatrixClient
|
||||
* @param userId - the userId of the sender
|
||||
* @param roomId - the id of the room we are in
|
||||
* @param ts - The timestamp of the event.
|
||||
* @returns The event
|
||||
*/
|
||||
export function mkReaction(
|
||||
target: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
ts?: number,
|
||||
): MatrixEvent {
|
||||
return mkEvent(
|
||||
{
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: target.getId()!,
|
||||
key: Math.random().toString(),
|
||||
},
|
||||
},
|
||||
ts,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
export function mkEdit(
|
||||
target: MatrixEvent,
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
msg?: string,
|
||||
ts?: number,
|
||||
) {
|
||||
msg = msg ?? `Edit of ${target.getId()}`;
|
||||
return mkEvent(
|
||||
{
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: userId,
|
||||
room: roomId,
|
||||
content: {
|
||||
"body": `* ${msg}`,
|
||||
"m.new_content": {
|
||||
body: msg,
|
||||
},
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: target.getId()!,
|
||||
},
|
||||
},
|
||||
ts,
|
||||
},
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mock implementation of webstorage
|
||||
*/
|
||||
|
||||
@@ -115,6 +115,26 @@ type MakeThreadProps = {
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
type MakeThreadResult = {
|
||||
/**
|
||||
* Thread model
|
||||
*/
|
||||
thread: Thread;
|
||||
/**
|
||||
* Thread root event
|
||||
*/
|
||||
rootEvent: MatrixEvent;
|
||||
/**
|
||||
* Events added to the thread
|
||||
*/
|
||||
events: MatrixEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a new thread in a room by creating a message as thread root.
|
||||
* Also creates a Thread model and adds it to the room.
|
||||
* Does not insert the messages into a timeline.
|
||||
*/
|
||||
export const mkThread = ({
|
||||
room,
|
||||
client,
|
||||
@@ -122,7 +142,7 @@ export const mkThread = ({
|
||||
participantUserIds,
|
||||
length = 2,
|
||||
ts = 1,
|
||||
}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => {
|
||||
}: MakeThreadProps): MakeThreadResult => {
|
||||
const { rootEvent, events } = makeThreadEvents({
|
||||
roomId: room.roomId,
|
||||
authorId,
|
||||
|
||||
@@ -745,3 +745,78 @@ export const REMOTE_SFU_DESCRIPTION =
|
||||
"a=sctp-port:5000\n" +
|
||||
"a=ice-ufrag:obZwzAcRtxwuozPZ\n" +
|
||||
"a=ice-pwd:TWXNaPeyKTTvRLyIQhWHfHlZHJjtcoKs";
|
||||
|
||||
export const groupCallParticipantsFourOtherDevices = new Map([
|
||||
[
|
||||
new RoomMember("roomId0", "user1"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId0",
|
||||
{
|
||||
sessionId: "0",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"deviceId1",
|
||||
{
|
||||
sessionId: "1",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"deviceId2",
|
||||
{
|
||||
sessionId: "2",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
[
|
||||
new RoomMember("roomId0", "user2"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId3",
|
||||
{
|
||||
sessionId: "0",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"deviceId4",
|
||||
{
|
||||
sessionId: "1",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
export const groupCallParticipantsOneOtherDevice = new Map([
|
||||
[
|
||||
new RoomMember("roomId1", "thisMember"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId0",
|
||||
{
|
||||
sessionId: "0",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
[
|
||||
new RoomMember("roomId1", "opponentMember"),
|
||||
new Map([
|
||||
[
|
||||
"deviceId1",
|
||||
{
|
||||
sessionId: "1",
|
||||
screensharing: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ limitations under the License.
|
||||
import MockHttpBackend from "matrix-mock-request";
|
||||
|
||||
import { AutoDiscovery } from "../../src/autodiscovery";
|
||||
import { OidcDiscoveryError } from "../../src/oidc/validate";
|
||||
|
||||
describe("AutoDiscovery", function () {
|
||||
const getHttpBackend = (): MockHttpBackend => {
|
||||
@@ -368,7 +369,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
);
|
||||
|
||||
it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + "m.homeserver", function () {
|
||||
it("should return SUCCESS when .well-known has a verifiably accurate base_url for m.homeserver", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
@@ -397,6 +398,10 @@ describe("AutoDiscovery", function () {
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -434,6 +439,54 @@ describe("AutoDiscovery", function () {
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return SUCCESS with authentication error when authentication config is invalid", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
.check((req) => {
|
||||
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
|
||||
})
|
||||
.respond(200, {
|
||||
versions: ["r0.0.1"],
|
||||
});
|
||||
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
|
||||
"m.homeserver": {
|
||||
// Note: we also expect this test to trim the trailing slash
|
||||
base_url: "https://chat.example.org/",
|
||||
},
|
||||
"m.authentication": {
|
||||
invalid: true,
|
||||
},
|
||||
});
|
||||
return Promise.all([
|
||||
httpBackend.flushAllExpected(),
|
||||
AutoDiscovery.findClientConfig("example.org").then((conf) => {
|
||||
const expected = {
|
||||
"m.homeserver": {
|
||||
state: "SUCCESS",
|
||||
error: null,
|
||||
base_url: "https://chat.example.org",
|
||||
},
|
||||
"m.identity_server": {
|
||||
state: "PROMPT",
|
||||
error: null,
|
||||
base_url: null,
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "FAIL_ERROR",
|
||||
error: OidcDiscoveryError.Misconfigured,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -625,7 +678,7 @@ describe("AutoDiscovery", function () {
|
||||
},
|
||||
);
|
||||
|
||||
it("should return SUCCESS when the identity server configuration is " + "verifiably accurate", function () {
|
||||
it("should return SUCCESS when the identity server configuration is verifiably accurate", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
@@ -664,6 +717,10 @@ describe("AutoDiscovery", function () {
|
||||
error: null,
|
||||
base_url: "https://identity.example.org",
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
@@ -671,7 +728,7 @@ describe("AutoDiscovery", function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return SUCCESS and preserve non-standard keys from the " + ".well-known response", function () {
|
||||
it("should return SUCCESS and preserve non-standard keys from the .well-known response", function () {
|
||||
const httpBackend = getHttpBackend();
|
||||
httpBackend
|
||||
.when("GET", "/_matrix/client/versions")
|
||||
@@ -716,6 +773,10 @@ describe("AutoDiscovery", function () {
|
||||
"org.example.custom.property": {
|
||||
cupcakes: "yes",
|
||||
},
|
||||
"m.authentication": {
|
||||
state: "IGNORE",
|
||||
error: OidcDiscoveryError.NotSupported,
|
||||
},
|
||||
};
|
||||
|
||||
expect(conf).toEqual(expected);
|
||||
|
||||
@@ -381,12 +381,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -617,12 +612,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await secondAliceClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -725,12 +715,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -805,12 +790,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -897,12 +877,7 @@ describe("Crypto", function () {
|
||||
event.senderCurve25519Key = null;
|
||||
// @ts-ignore private properties
|
||||
event.claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient.crypto!.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -148,22 +148,14 @@ describe("Secrets", function () {
|
||||
it("should throw if given a key that doesn't exist", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar", ["this secret does not exist"]);
|
||||
// should be able to use expect(...).toThrow() but mocha still fails
|
||||
// the test even when it throws for reasons I have no inclination to debug
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should refuse to encrypt with zero keys", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar", []);
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
@@ -214,10 +206,7 @@ describe("Secrets", function () {
|
||||
it("should refuse to encrypt if no keys given and no default key", async function () {
|
||||
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
|
||||
|
||||
try {
|
||||
await alice.storeSecret("foo", "bar");
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {}
|
||||
await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(aliceSasEvent.sas);
|
||||
e.confirm();
|
||||
aliceSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
aliceSasEvent.mismatch();
|
||||
}
|
||||
@@ -169,7 +169,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(bobSasEvent.sas);
|
||||
e.confirm();
|
||||
bobSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
bobSasEvent.mismatch();
|
||||
}
|
||||
@@ -519,7 +519,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(aliceSasEvent.sas);
|
||||
e.confirm();
|
||||
aliceSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
aliceSasEvent.mismatch();
|
||||
}
|
||||
@@ -543,7 +543,7 @@ describe("SAS verification", function () {
|
||||
expect(e.sas).toEqual(bobSasEvent.sas);
|
||||
e.confirm();
|
||||
bobSasEvent.confirm();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
e.mismatch();
|
||||
bobSasEvent.mismatch();
|
||||
}
|
||||
|
||||
@@ -24,13 +24,10 @@ import {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from "../../src";
|
||||
import { FeatureSupport, Thread } from "../../src/models/thread";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { ReEmitter } from "../../src/ReEmitter";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
describe("EventTimelineSet", () => {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -145,13 +142,6 @@ describe("EventTimelineSet", () => {
|
||||
});
|
||||
|
||||
describe("addEventToTimeline", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
it("Adds event to timeline", () => {
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
@@ -170,6 +160,15 @@ describe("EventTimelineSet", () => {
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true, false);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEventToTimeline (thread timeline)", () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
it("should not add an event to a timeline that does not belong to the timelineSet", () => {
|
||||
const eventTimelineSet2 = new EventTimelineSet(room);
|
||||
@@ -200,94 +199,19 @@ describe("EventTimelineSet", () => {
|
||||
const liveTimeline = eventTimelineSetForThread.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
eventTimelineSetForThread.addEventToTimeline(messageEvent, liveTimeline, {
|
||||
const normalMessage = utils.mkMessage({
|
||||
room: roomId,
|
||||
user: userA,
|
||||
msg: "Hello!",
|
||||
event: true,
|
||||
});
|
||||
|
||||
eventTimelineSetForThread.addEventToTimeline(normalMessage, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it("should allow edits to be added to thread timeline", async () => {
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
|
||||
const sender = "@alice:matrix.org";
|
||||
|
||||
const root = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
body: "Thread root",
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
room.addLiveEvents([root]);
|
||||
|
||||
const threadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": "Thread reply",
|
||||
"m.relates_to": {
|
||||
event_id: root.getId()!,
|
||||
rel_type: RelationType.Thread,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
root.setUnsigned({
|
||||
"m.relations": {
|
||||
[RelationType.Thread]: {
|
||||
count: 1,
|
||||
latest_event: {
|
||||
content: threadReply.getContent(),
|
||||
origin_server_ts: 5,
|
||||
room_id: room.roomId,
|
||||
sender,
|
||||
type: EventType.RoomMessage,
|
||||
event_id: threadReply.getId()!,
|
||||
user_id: sender,
|
||||
age: 1,
|
||||
},
|
||||
current_user_participated: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editToThreadReply = utils.mkEvent({
|
||||
event: true,
|
||||
content: {
|
||||
"body": " * edit",
|
||||
"m.new_content": {
|
||||
"body": "edit",
|
||||
"msgtype": "m.text",
|
||||
"org.matrix.msc1767.text": "edit",
|
||||
},
|
||||
"m.relates_to": {
|
||||
event_id: threadReply.getId()!,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
},
|
||||
type: EventType.RoomMessage,
|
||||
sender,
|
||||
});
|
||||
|
||||
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
|
||||
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(client, "relations").mockResolvedValue({
|
||||
events: [],
|
||||
});
|
||||
|
||||
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
|
||||
thread.once(RoomEvent.TimelineReset, () => {
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
expect(lastEvent.getContent().body).toBe(" * edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-room timeline", () => {
|
||||
it("Adds event to timeline", () => {
|
||||
const nonRoomEventTimelineSet = new EventTimelineSet(
|
||||
@@ -421,7 +345,9 @@ describe("EventTimelineSet", () => {
|
||||
});
|
||||
|
||||
it("should return true if the timeline set is not for a thread and the event is a thread root", () => {
|
||||
const thread = new Thread(messageEvent.getId()!, messageEvent, { room, client });
|
||||
const eventTimelineSet = new EventTimelineSet(room, {}, client);
|
||||
messageEvent.setThread(thread);
|
||||
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FetchHttpApi } from "../../../src/http-api/fetch";
|
||||
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
|
||||
import { ClientPrefix, HttpApiEvent, HttpApiEventHandlerMap, IdentityPrefix, IHttpOpts, Method } from "../../../src";
|
||||
import { emitPromise } from "../../test-utils/test-utils";
|
||||
import { QueryDict } from "../../../src/utils";
|
||||
|
||||
describe("FetchHttpApi", () => {
|
||||
const baseUrl = "http://baseUrl";
|
||||
@@ -235,4 +236,58 @@ describe("FetchHttpApi", () => {
|
||||
expect(fetchFn.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl()", () => {
|
||||
const localBaseUrl = "http://baseurl";
|
||||
const baseUrlWithTrailingSlash = "http://baseurl/";
|
||||
const makeApi = (thisBaseUrl = baseUrl): FetchHttpApi<any> => {
|
||||
const fetchFn = jest.fn();
|
||||
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
|
||||
return new FetchHttpApi(emitter, { baseUrl: thisBaseUrl, prefix, fetchFn });
|
||||
};
|
||||
|
||||
type TestParams = {
|
||||
path: string;
|
||||
queryParams?: QueryDict;
|
||||
prefix?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
type TestCase = [TestParams, string];
|
||||
const queryParams: QueryDict = {
|
||||
test1: 99,
|
||||
test2: ["a", "b"],
|
||||
};
|
||||
const testPrefix = "/just/testing";
|
||||
const testUrl = "http://justtesting.com";
|
||||
const testUrlWithTrailingSlash = "http://justtesting.com/";
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
[{ path: "/terms" }, `${localBaseUrl}${prefix}/terms`],
|
||||
[{ path: "/terms", queryParams }, `${localBaseUrl}${prefix}/terms?test1=99&test2=a&test2=b`],
|
||||
[{ path: "/terms", prefix: testPrefix }, `${localBaseUrl}${testPrefix}/terms`],
|
||||
[{ path: "/terms", baseUrl: testUrl }, `${testUrl}${prefix}/terms`],
|
||||
[{ path: "/terms", baseUrl: testUrlWithTrailingSlash }, `${testUrl}${prefix}/terms`],
|
||||
[
|
||||
{ path: "/terms", queryParams, prefix: testPrefix, baseUrl: testUrl },
|
||||
`${testUrl}${testPrefix}/terms?test1=99&test2=a&test2=b`,
|
||||
],
|
||||
];
|
||||
const runTests = (fetchBaseUrl: string) => {
|
||||
it.each<TestCase>(testCases)(
|
||||
"creates url with params %s",
|
||||
({ path, queryParams, prefix, baseUrl }, result) => {
|
||||
const api = makeApi(fetchBaseUrl);
|
||||
|
||||
expect(api.getUrl(path, queryParams, prefix, baseUrl)).toEqual(new URL(result));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("when fetch.opts.baseUrl does not have a trailing slash", () => {
|
||||
runTests(localBaseUrl);
|
||||
});
|
||||
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
|
||||
runTests(baseUrlWithTrailingSlash);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -517,4 +517,47 @@ describe("InteractiveAuth", () => {
|
||||
expect(ia.getEmailSid()).toEqual(sid);
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritise shorter flows", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: [AuthType.Recaptcha, AuthType.Password] }, { stages: [AuthType.Password] }],
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
|
||||
it("should prioritise flows with entirely supported stages", async () => {
|
||||
const doRequest = jest.fn();
|
||||
const stateUpdated = jest.fn();
|
||||
|
||||
const ia = new InteractiveAuth({
|
||||
matrixClient: getFakeClient(),
|
||||
doRequest: doRequest,
|
||||
stateUpdated: stateUpdated,
|
||||
requestEmailToken: jest.fn(),
|
||||
authData: {
|
||||
session: "sessionId",
|
||||
flows: [{ stages: ["com.devture.shared_secret_auth"] }, { stages: [AuthType.Password] }],
|
||||
params: {},
|
||||
},
|
||||
supportedStages: [AuthType.Password],
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
ia.chooseStage();
|
||||
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { ClientPrefix, MatrixClient } from "../../src";
|
||||
import { SSOAction } from "../../src/@types/auth";
|
||||
import { TestClient } from "../TestClient";
|
||||
|
||||
function createExampleMatrixClient(): MatrixClient {
|
||||
return new MatrixClient({
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Login request", function () {
|
||||
let client: TestClient;
|
||||
|
||||
@@ -57,3 +66,84 @@ describe("SSO login URL", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshToken", () => {
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it("requests the correctly-prefixed /refresh endpoint when server correctly accepts /v3", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
const response = {
|
||||
access_token: "access_token",
|
||||
refresh_token: "refresh_token",
|
||||
expires_in_ms: 30000,
|
||||
};
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), response);
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), () => {
|
||||
throw new Error("/v1/refresh unexpectedly called");
|
||||
});
|
||||
|
||||
const refreshResult = await client.refreshToken("initial_refresh_token");
|
||||
expect(refreshResult).toEqual(response);
|
||||
});
|
||||
|
||||
it("falls back to /v1 when server does not recognized /v3 refresh", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
const response = {
|
||||
access_token: "access_token",
|
||||
refresh_token: "refresh_token",
|
||||
expires_in_ms: 30000,
|
||||
};
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), response);
|
||||
|
||||
const refreshResult = await client.refreshToken("initial_refresh_token");
|
||||
expect(refreshResult).toEqual(response);
|
||||
});
|
||||
|
||||
it("re-raises M_UNRECOGNIZED exceptions from /v1", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
|
||||
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ errcode: "M_UNRECOGNIZED" });
|
||||
});
|
||||
|
||||
it("re-raises non-M_UNRECOGNIZED exceptions from /v3", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), 429);
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), () => {
|
||||
throw new Error("/v1/refresh unexpectedly called");
|
||||
});
|
||||
|
||||
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
});
|
||||
|
||||
it("re-raises non-M_UNRECOGNIZED exceptions from /v1", async () => {
|
||||
const client = createExampleMatrixClient();
|
||||
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V3).toString(), {
|
||||
status: 400,
|
||||
body: { errcode: "M_UNRECOGNIZED" },
|
||||
});
|
||||
fetchMock.postOnce(client.http.getUrl("/refresh", undefined, ClientPrefix.V1).toString(), 429);
|
||||
|
||||
expect(client.refreshToken("initial_refresh_token")).rejects.toMatchObject({ httpStatus: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
UNSTABLE_MSC3088_ENABLED,
|
||||
UNSTABLE_MSC3088_PURPOSE,
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
MSC3912_RELATION_BASED_REDACTIONS_PROP,
|
||||
} from "../../src/@types/event";
|
||||
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
|
||||
import { Crypto } from "../../src/crypto";
|
||||
@@ -181,9 +180,7 @@ describe("MatrixClient", function () {
|
||||
data: SYNC_DATA,
|
||||
};
|
||||
|
||||
const unstableFeatures: Record<string, boolean> = {
|
||||
"org.matrix.msc3440.stable": true,
|
||||
};
|
||||
let unstableFeatures: Record<string, boolean> = {};
|
||||
|
||||
// items are popped off when processed and block if no items left.
|
||||
let httpLookups: HttpLookup[] = [];
|
||||
@@ -342,6 +339,12 @@ describe("MatrixClient", function () {
|
||||
store.getClientOptions = jest.fn().mockReturnValue(Promise.resolve(null));
|
||||
store.storeClientOptions = jest.fn().mockReturnValue(Promise.resolve(null));
|
||||
store.isNewlyCreated = jest.fn().mockReturnValue(Promise.resolve(true));
|
||||
|
||||
// set unstableFeatures to a defined state before each test
|
||||
unstableFeatures = {
|
||||
"org.matrix.msc3440.stable": true,
|
||||
};
|
||||
|
||||
makeClient();
|
||||
|
||||
// set reasonable working defaults
|
||||
@@ -1373,10 +1376,10 @@ describe("MatrixClient", function () {
|
||||
await client.redactEvent(roomId, eventId, txnId, { reason });
|
||||
});
|
||||
|
||||
describe("when calling with with_relations", () => {
|
||||
describe("when calling with 'with_rel_types'", () => {
|
||||
const eventId = "$event42:example.org";
|
||||
|
||||
it("should raise an error if server has no support for relation based redactions", async () => {
|
||||
it("should raise an error if the server has no support for relation based redactions", async () => {
|
||||
// load supported features
|
||||
await client.getVersions();
|
||||
|
||||
@@ -1384,7 +1387,7 @@ describe("MatrixClient", function () {
|
||||
|
||||
expect(() => {
|
||||
client.redactEvent(roomId, eventId, txnId, {
|
||||
with_relations: [RelationType.Reference],
|
||||
with_rel_types: [RelationType.Reference],
|
||||
});
|
||||
}).toThrow(
|
||||
new Error(
|
||||
@@ -1394,34 +1397,30 @@ describe("MatrixClient", function () {
|
||||
);
|
||||
});
|
||||
|
||||
describe("and the server supports relation based redactions (unstable)", () => {
|
||||
beforeEach(async () => {
|
||||
unstableFeatures["org.matrix.msc3912"] = true;
|
||||
// load supported features
|
||||
await client.getVersions();
|
||||
});
|
||||
it("and the server has unstable support for relation based redactions, it should send 'org.matrix.msc3912.with_relations' in the request body", async () => {
|
||||
unstableFeatures["org.matrix.msc3912"] = true;
|
||||
// load supported features
|
||||
await client.getVersions();
|
||||
|
||||
it("should send with_relations in the request body", async () => {
|
||||
const txnId = client.makeTxnId();
|
||||
const txnId = client.makeTxnId();
|
||||
|
||||
httpLookups = [
|
||||
{
|
||||
method: "PUT",
|
||||
path:
|
||||
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
|
||||
`/${encodeURIComponent(txnId)}`,
|
||||
expectBody: {
|
||||
reason: "redaction test",
|
||||
[MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: [RelationType.Reference],
|
||||
},
|
||||
data: { event_id: eventId },
|
||||
httpLookups = [
|
||||
{
|
||||
method: "PUT",
|
||||
path:
|
||||
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
|
||||
`/${encodeURIComponent(txnId)}`,
|
||||
expectBody: {
|
||||
reason: "redaction test",
|
||||
["org.matrix.msc3912.with_relations"]: ["m.reference"],
|
||||
},
|
||||
];
|
||||
data: { event_id: eventId },
|
||||
},
|
||||
];
|
||||
|
||||
await client.redactEvent(roomId, eventId, txnId, {
|
||||
reason: "redaction test",
|
||||
with_relations: [RelationType.Reference],
|
||||
});
|
||||
await client.redactEvent(roomId, eventId, txnId, {
|
||||
reason: "redaction test",
|
||||
with_rel_types: [RelationType.Reference],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
|
||||
import { mkThread } from "../../test-utils/thread";
|
||||
import { Room, RoomEvent } from "../../../src/models/room";
|
||||
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, FeatureSupport } from "../../../src/models/thread";
|
||||
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, MatrixEvent } from "../../../src";
|
||||
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
|
||||
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
|
||||
import { ReceiptType } from "../../../src/@types/read_receipts";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
|
||||
import { ReEmitter } from "../../../src/ReEmitter";
|
||||
import { Feature, ServerSupport } from "../../../src/feature";
|
||||
import { eventMapperFor } from "../../../src/event-mapper";
|
||||
|
||||
describe("Thread", () => {
|
||||
describe("constructor", () => {
|
||||
@@ -424,4 +427,301 @@ describe("Thread", () => {
|
||||
expect(mock).toHaveBeenCalledWith("b1", "f1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertEventIntoTimeline", () => {
|
||||
it("Inserts a reaction in timestamp order", () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
const client = createClientWithEventMapper();
|
||||
const userId = "user1";
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread with a root plus 5 messages
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: userId,
|
||||
participantUserIds: ["@bob:hs", "@chia:hs", "@dv:hs"],
|
||||
length: 6,
|
||||
ts: 100, // Events will be at ts 100, 101, 102, 103, 104 and 105
|
||||
});
|
||||
|
||||
// When we insert a reaction to the second thread message
|
||||
const replyEvent = mkReaction(events[2], client, userId, room.roomId, 104);
|
||||
thread.insertEventIntoTimeline(replyEvent);
|
||||
|
||||
// Then the reaction is inserted based on its timestamp
|
||||
expect(thread.events.map((ev) => ev.getId())).toEqual([
|
||||
events[0].getId(),
|
||||
events[1].getId(),
|
||||
events[2].getId(),
|
||||
events[3].getId(),
|
||||
events[4].getId(),
|
||||
replyEvent.getId(),
|
||||
events[5].getId(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Without relations recursion support", () => {
|
||||
it("Creates a local echo receipt for new events", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client without relations recursion support
|
||||
const client = createClientWithEventMapper();
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt).toBeTruthy();
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
expect(receipt?.data.ts).toEqual(100);
|
||||
expect(receipt?.data.thread_id).toEqual(thread.id);
|
||||
|
||||
// (And the receipt was synthetic)
|
||||
expect(thread.getReadReceiptForUserId(userId, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("Doesn't create a local echo receipt for events before an existing receipt", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client without relations recursion support
|
||||
const client = createClientWithEventMapper();
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
|
||||
// Then no receipt was added to the thread (the receipt is still
|
||||
// for the thread root). This happens because since we have no
|
||||
// recursive relations support, we know that sometimes events
|
||||
// appear out of order, so we have to check their timestamps as
|
||||
// a guess of the correct order.
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("With relations recursion support", () => {
|
||||
it("Creates a local echo receipt for new events", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client WITH relations recursion support
|
||||
const client = createClientWithEventMapper(
|
||||
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
|
||||
);
|
||||
|
||||
// And a thread with an added event (with later timestamp)
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
});
|
||||
|
||||
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
|
||||
// Assumption: no server side support because if we have it, events
|
||||
// can only be added to the timeline after the thread has been
|
||||
// initialised, and we are not properly initialising it here.
|
||||
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
|
||||
|
||||
// Given a client WITH relations recursion support
|
||||
const client = createClientWithEventMapper(
|
||||
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
|
||||
);
|
||||
|
||||
// And a thread with an added event with a lower timestamp than its other events
|
||||
const userId = "user1";
|
||||
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
|
||||
|
||||
// Then a receipt was added to the thread, because relations
|
||||
// recursion is available, so we trust the server to have
|
||||
// provided us with events in the right order.
|
||||
const receipt = thread.getReadReceiptForUserId(userId);
|
||||
expect(receipt?.eventId).toEqual(message.getId());
|
||||
});
|
||||
});
|
||||
|
||||
async function createThreadAndEvent(
|
||||
client: MatrixClient,
|
||||
rootTs: number,
|
||||
eventTs: number,
|
||||
userId: string,
|
||||
): Promise<{ thread: Thread; message: MatrixEvent }> {
|
||||
const room = new Room("room1", client, userId);
|
||||
|
||||
// Given a thread
|
||||
const { thread } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: userId,
|
||||
participantUserIds: [],
|
||||
ts: rootTs,
|
||||
});
|
||||
// Sanity: the current receipt is for the thread root
|
||||
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
|
||||
|
||||
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
|
||||
|
||||
// When we add a message that is before the latest receipt
|
||||
const message = makeThreadEvent({
|
||||
event: true,
|
||||
rootEventId: thread.id,
|
||||
replyToEventId: thread.id,
|
||||
user: userId,
|
||||
room: room.roomId,
|
||||
ts: eventTs,
|
||||
});
|
||||
await thread.addEvent(message, false, true);
|
||||
await awaitTimelineEvent;
|
||||
|
||||
return { thread, message };
|
||||
}
|
||||
|
||||
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
|
||||
const client = mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = canSupport;
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
mocked(client.supportsThreads).mockReturnValue(true);
|
||||
return client;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Editing events", () => {
|
||||
describe("Given server support for threads", () => {
|
||||
let previousThreadHasServerSideSupport: FeatureSupport;
|
||||
|
||||
beforeAll(() => {
|
||||
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
|
||||
Thread.hasServerSideSupport = FeatureSupport.Stable;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
|
||||
});
|
||||
|
||||
it("Adds edits from sync to the thread timeline and applies them", async () => {
|
||||
// Given a thread
|
||||
const client = createClient();
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
|
||||
// When a message and an edit are added to the thread
|
||||
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
|
||||
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
|
||||
await thread.addEvent(messageToEdit, false);
|
||||
await thread.addEvent(editEvent, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(editEvent);
|
||||
expect(secondLastEvent).toBe(messageToEdit);
|
||||
|
||||
// And the first message has been edited
|
||||
expect(secondLastEvent.getContent().body).toEqual("edit");
|
||||
});
|
||||
|
||||
it("Adds edits fetched on demand to the thread timeline and applies them", async () => {
|
||||
// Given we don't support recursive relations
|
||||
const client = createClient(new Map([[Feature.RelationsRecursion, ServerSupport.Unsupported]]));
|
||||
// And we have a thread
|
||||
const user = "@alice:matrix.org";
|
||||
const room = "!room:z";
|
||||
const thread = await createThread(client, user, room);
|
||||
|
||||
// When a message is added to the thread, and an edit to it is provided on demand
|
||||
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
|
||||
// (fetchEditsWhereNeeded only applies to encrypted messages for some reason)
|
||||
messageToEdit.event.type = EventType.RoomMessageEncrypted;
|
||||
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
|
||||
mocked(client.relations).mockImplementation(async (_roomId, eventId) => {
|
||||
if (eventId === messageToEdit.getId()) {
|
||||
return { events: [editEvent] };
|
||||
} else {
|
||||
return { events: [] };
|
||||
}
|
||||
});
|
||||
await thread.addEvent(messageToEdit, false);
|
||||
|
||||
// Then both events end up in the timeline
|
||||
const lastEvent = thread.timeline.at(-1)!;
|
||||
const secondLastEvent = thread.timeline.at(-2)!;
|
||||
expect(lastEvent).toBe(editEvent);
|
||||
expect(secondLastEvent).toBe(messageToEdit);
|
||||
|
||||
// And the first message has been edited
|
||||
expect(secondLastEvent.getContent().body).toEqual("edit");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a message event that lives in a thread
|
||||
*/
|
||||
function createThreadMessage(threadId: string, user: string, room: string, msg: string): MatrixEvent {
|
||||
return makeThreadEvent({
|
||||
event: true,
|
||||
user,
|
||||
room,
|
||||
msg,
|
||||
rootEventId: threadId,
|
||||
replyToEventId: threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread and wait for it to be properly initialised (so you can safely
|
||||
* add events to it and expect them to appear in the timeline.
|
||||
*/
|
||||
async function createThread(client: MatrixClient, user: string, roomId: string): Promise<Thread> {
|
||||
const root = mkMessage({ event: true, user, room: roomId, msg: "Thread root" });
|
||||
const room = new Room(roomId, client, "@roomcreator:x");
|
||||
|
||||
// Ensure the root is in the room timeline
|
||||
root.setThreadId(root.getId());
|
||||
await room.addLiveEvents([root]);
|
||||
|
||||
// Create the thread and wait for it to be initialised
|
||||
const thread = room.createThread(root.getId()!, root, [], false);
|
||||
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MatrixClient that supports threads and has all the methods used when
|
||||
* creating a thread that call out to HTTP endpoints mocked out.
|
||||
*/
|
||||
function createClient(canSupport = new Map()): MatrixClient {
|
||||
const client = mock(MatrixClient, "MatrixClient");
|
||||
client.reEmitter = mock(ReEmitter, "ReEmitter");
|
||||
client.canSupport = canSupport;
|
||||
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
|
||||
|
||||
// Mock methods that call out to HTTP endpoints
|
||||
jest.spyOn(client, "paginateEventTimeline").mockResolvedValue(true);
|
||||
jest.spyOn(client, "relations").mockResolvedValue({ events: [] });
|
||||
jest.spyOn(client, "fetchRoomEvent").mockResolvedValue({});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { M_AUTHENTICATION } from "../../../src";
|
||||
import { logger } from "../../../src/logger";
|
||||
import {
|
||||
OidcDiscoveryError,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "../../../src/oidc/validate";
|
||||
|
||||
describe("validateWellKnownAuthentication()", () => {
|
||||
const baseWk = {
|
||||
"m.homeserver": {
|
||||
base_url: "https://hs.org",
|
||||
},
|
||||
};
|
||||
it("should throw not supported error when wellKnown has no m.authentication section", () => {
|
||||
expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication issuer is not a string", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: { url: "test.com" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is not a string", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
account: { url: "test" },
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
});
|
||||
|
||||
it("should throw misconfigured error when authentication account is false", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
account: false,
|
||||
},
|
||||
};
|
||||
expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured);
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses stable m.authentication", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return valid config when m.authentication account is missing", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove unexpected properties", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.stable!]: {
|
||||
issuer: "test.com",
|
||||
somethingElse: "test",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
issuer: "test.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return valid config when wk uses unstable prefix for m.authentication", () => {
|
||||
const wk = {
|
||||
...baseWk,
|
||||
[M_AUTHENTICATION.unstable!]: {
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
},
|
||||
};
|
||||
expect(validateWellKnownAuthentication(wk)).toEqual({
|
||||
issuer: "test.com",
|
||||
account: "account.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOIDCIssuerWellKnown", () => {
|
||||
const validWk: any = {
|
||||
authorization_endpoint: "https://test.org/authorize",
|
||||
token_endpoint: "https://authorize.org/token",
|
||||
registration_endpoint: "https://authorize.org/regsiter",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
};
|
||||
beforeEach(() => {
|
||||
// stub to avoid console litter
|
||||
jest.spyOn(logger, "error")
|
||||
.mockClear()
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("should throw OP support error when wellKnown is not an object", () => {
|
||||
expect(() => {
|
||||
validateOIDCIssuerWellKnown([]);
|
||||
}).toThrow(OidcDiscoveryError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed");
|
||||
});
|
||||
|
||||
it("should log all errors before throwing", () => {
|
||||
expect(() => {
|
||||
validateOIDCIssuerWellKnown({
|
||||
...validWk,
|
||||
authorization_endpoint: undefined,
|
||||
response_types_supported: [],
|
||||
});
|
||||
}).toThrow(OidcDiscoveryError.OpSupport);
|
||||
expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"OIDC issuer configuration: response_types_supported is invalid. code is required.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return validated issuer config", () => {
|
||||
expect(validateOIDCIssuerWellKnown(validWk)).toEqual({
|
||||
authorizationEndpoint: validWk.authorization_endpoint,
|
||||
tokenEndpoint: validWk.token_endpoint,
|
||||
registrationEndpoint: validWk.registration_endpoint,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return validated issuer config without registrationendpoint", () => {
|
||||
const wk = { ...validWk };
|
||||
delete wk.registration_endpoint;
|
||||
expect(validateOIDCIssuerWellKnown(wk)).toEqual({
|
||||
authorizationEndpoint: validWk.authorization_endpoint,
|
||||
tokenEndpoint: validWk.token_endpoint,
|
||||
registrationEndpoint: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
type TestCase = [string, any];
|
||||
it.each<TestCase>([
|
||||
["authorization_endpoint", undefined],
|
||||
["authorization_endpoint", { not: "a string" }],
|
||||
["token_endpoint", undefined],
|
||||
["token_endpoint", { not: "a string" }],
|
||||
["registration_endpoint", { not: "a string" }],
|
||||
["response_types_supported", undefined],
|
||||
["response_types_supported", "not an array"],
|
||||
["response_types_supported", ["doesnt include code"]],
|
||||
["grant_types_supported", undefined],
|
||||
["grant_types_supported", "not an array"],
|
||||
["grant_types_supported", ["doesnt include authorization_code"]],
|
||||
["code_challenge_methods_supported", undefined],
|
||||
["code_challenge_methods_supported", "not an array"],
|
||||
["code_challenge_methods_supported", ["doesnt include S256"]],
|
||||
])("should throw OP support error when %s is %s", (key, value) => {
|
||||
const wk = {
|
||||
...validWk,
|
||||
[key]: value,
|
||||
};
|
||||
expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport);
|
||||
});
|
||||
});
|
||||
@@ -97,51 +97,6 @@ describe("NotificationService", function () {
|
||||
pattern: "foo*bar",
|
||||
rule_id: "foobar",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "p[io]ng",
|
||||
rule_id: "pingpong",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "I ate [0-9] pies",
|
||||
rule_id: "pies",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"notify",
|
||||
{
|
||||
set_tweak: "sound",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
set_tweak: "highlight",
|
||||
},
|
||||
],
|
||||
enabled: true,
|
||||
pattern: "b[!ai]ke",
|
||||
rule_id: "bakebike",
|
||||
},
|
||||
],
|
||||
override: [
|
||||
{
|
||||
@@ -289,39 +244,6 @@ describe("NotificationService", function () {
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
// TODO: This is not spec compliant behaviour.
|
||||
//
|
||||
// See https://spec.matrix.org/v1.5/client-server-api/#conditions-1 which
|
||||
// describes pattern should glob:
|
||||
//
|
||||
// 1. * matches 0 or more characters;
|
||||
// 2. ? matches exactly one character
|
||||
it("should bing on character group ([abc]) bing words.", function () {
|
||||
testEvent.event.content!.body = "Ping!";
|
||||
let actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
testEvent.event.content!.body = "Pong!";
|
||||
actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
// TODO: This is not spec compliant behaviour. (See above.)
|
||||
it("should bing on character range ([a-z]) bing words.", function () {
|
||||
testEvent.event.content!.body = "I ate 6 pies";
|
||||
const actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
});
|
||||
|
||||
// TODO: This is not spec compliant behaviour. (See above.)
|
||||
it("should bing on character negation ([!a]) bing words.", function () {
|
||||
testEvent.event.content!.body = "boke";
|
||||
let actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(true);
|
||||
testEvent.event.content!.body = "bake";
|
||||
actions = pushProcessor.actionsForEvent(testEvent);
|
||||
expect(actions.tweaks.highlight).toEqual(false);
|
||||
});
|
||||
|
||||
it("should not bing on room server ACL changes", function () {
|
||||
testEvent = utils.mkEvent({
|
||||
type: EventType.RoomServerAcl,
|
||||
|
||||
@@ -14,9 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
|
||||
import { EventType, RoomState } from "../../src";
|
||||
import {
|
||||
createClient,
|
||||
EventType,
|
||||
MatrixClient,
|
||||
RoomState,
|
||||
UNSTABLE_MSC2666_MUTUAL_ROOMS,
|
||||
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
|
||||
UNSTABLE_MSC2666_SHARED_ROOMS,
|
||||
} from "../../src";
|
||||
|
||||
describe("RoomMember", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@@ -481,3 +491,125 @@ describe("RoomMember", function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MutualRooms", () => {
|
||||
let client: MatrixClient;
|
||||
const HS_URL = "https://example.com";
|
||||
const TEST_USER_ID = "@alice:localhost";
|
||||
const TEST_DEVICE_ID = "xzcvb";
|
||||
const QUERIED_USER = "@user:example.com";
|
||||
|
||||
beforeEach(async () => {
|
||||
// anything that we don't have a specific matcher for silently returns a 404
|
||||
fetchMock.catch(404);
|
||||
fetchMock.config.warnOnFallback = true;
|
||||
|
||||
client = createClient({
|
||||
baseUrl: HS_URL,
|
||||
userId: TEST_USER_ID,
|
||||
accessToken: "akjgkrgjs",
|
||||
deviceId: TEST_DEVICE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.stopClient();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
function enableFeature(feature: string) {
|
||||
const mapping: Record<string, boolean> = {};
|
||||
|
||||
mapping[feature] = true;
|
||||
|
||||
fetchMock.get(`${HS_URL}/_matrix/client/versions`, {
|
||||
unstable_features: mapping,
|
||||
versions: ["v1.1"],
|
||||
});
|
||||
}
|
||||
|
||||
it("supports the initial MSC version (shared rooms)", async () => {
|
||||
enableFeature(UNSTABLE_MSC2666_SHARED_ROOMS);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/shared_rooms/:user_id", (rawUrl) => {
|
||||
const segments = rawUrl.split("/");
|
||||
const lastSegment = decodeURIComponent(segments[segments.length - 1]);
|
||||
|
||||
expect(lastSegment).toEqual(QUERIED_USER);
|
||||
|
||||
return {
|
||||
joined: ["!test:example.com"],
|
||||
};
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!test:example.com"]);
|
||||
});
|
||||
|
||||
it("supports the renaming MSC version (mutual rooms)", async () => {
|
||||
enableFeature(UNSTABLE_MSC2666_MUTUAL_ROOMS);
|
||||
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms/:user_id", (rawUrl) => {
|
||||
const segments = rawUrl.split("/");
|
||||
const lastSegment = decodeURIComponent(segments[segments.length - 1]);
|
||||
|
||||
expect(lastSegment).toEqual(QUERIED_USER);
|
||||
|
||||
return {
|
||||
joined: ["!test2:example.com"],
|
||||
};
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!test2:example.com"]);
|
||||
});
|
||||
|
||||
describe("can work the latest MSC version (query mutual rooms)", () => {
|
||||
beforeEach(() => {
|
||||
enableFeature(UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS);
|
||||
});
|
||||
|
||||
it("works with a simple response", async () => {
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms", (rawUrl) => {
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
expect(url.searchParams.get("user_id")).toEqual(QUERIED_USER);
|
||||
|
||||
return {
|
||||
joined: ["!test3:example.com"],
|
||||
};
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!test3:example.com"]);
|
||||
});
|
||||
|
||||
it("works with a paginated response", async () => {
|
||||
fetchMock.get("express:/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms", (rawUrl) => {
|
||||
const url = new URL(rawUrl);
|
||||
|
||||
expect(url.searchParams.get("user_id")).toEqual(QUERIED_USER);
|
||||
|
||||
const token = url.searchParams.get("batch_token");
|
||||
|
||||
if (token == "yahaha") {
|
||||
return {
|
||||
joined: ["!korok:example.com"],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
joined: ["!rock:example.com"],
|
||||
next_batch_token: "yahaha",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const rooms = await client._unstable_getSharedRooms(QUERIED_USER);
|
||||
|
||||
expect(rooms).toEqual(["!rock:example.com", "!korok:example.com"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+268
-266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Mocked } from "jest-mock";
|
||||
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
|
||||
describe("CrossSigningIdentity", () => {
|
||||
describe("bootstrapCrossSigning", () => {
|
||||
/** the CrossSigningIdentity implementation under test */
|
||||
let crossSigning: CrossSigningIdentity;
|
||||
|
||||
/** a mocked-up OlmMachine which crossSigning is connected to */
|
||||
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
/** A mock OutgoingRequestProcessor which crossSigning is connected to */
|
||||
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await RustSdkCryptoJs.initAsync();
|
||||
|
||||
olmMachine = {
|
||||
crossSigningStatus: jest.fn(),
|
||||
bootstrapCrossSigning: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
|
||||
|
||||
outgoingRequestProcessor = {
|
||||
makeOutgoingRequest: jest.fn(),
|
||||
} as unknown as Mocked<OutgoingRequestProcessor>;
|
||||
|
||||
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor);
|
||||
});
|
||||
|
||||
it("should do nothing if keys are present on-device and in secret storage", async () => {
|
||||
olmMachine.crossSigningStatus.mockResolvedValue({
|
||||
hasMaster: true,
|
||||
hasSelfSigning: true,
|
||||
hasUserSigning: true,
|
||||
});
|
||||
// TODO: secret storage
|
||||
await crossSigning.bootstrapCrossSigning({});
|
||||
expect(olmMachine.bootstrapCrossSigning).not.toHaveBeenCalled();
|
||||
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call bootstrapCrossSigning if a reset is forced", async () => {
|
||||
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
|
||||
await crossSigning.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should call bootstrapCrossSigning if we need new keys", async () => {
|
||||
olmMachine.crossSigningStatus.mockResolvedValue({
|
||||
hasMaster: false,
|
||||
hasSelfSigning: false,
|
||||
hasUserSigning: false,
|
||||
});
|
||||
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
|
||||
await crossSigning.bootstrapCrossSigning({});
|
||||
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
|
||||
import { IEventDecryptionResult } from "../../../src/@types/crypto";
|
||||
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
import { ImportRoomKeysOpts } from "../../../src/crypto-api";
|
||||
|
||||
afterEach(() => {
|
||||
// reset fake-indexeddb after each test, to make sure we don't leak connections
|
||||
@@ -40,16 +41,59 @@ const TEST_USER = "@alice:example.com";
|
||||
const TEST_DEVICE_ID = "TEST_DEVICE";
|
||||
|
||||
describe("RustCrypto", () => {
|
||||
describe(".exportRoomKeys", () => {
|
||||
describe(".importRoomKeys and .exportRoomKeys", () => {
|
||||
let rustCrypto: RustCrypto;
|
||||
|
||||
beforeEach(async () => {
|
||||
rustCrypto = await makeTestRustCrypto();
|
||||
});
|
||||
|
||||
it("should return a list", async () => {
|
||||
it("should import and export keys", async () => {
|
||||
const someRoomKeys = [
|
||||
{
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
|
||||
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
|
||||
session_id: "iGQG5GaP1/B3dSH6zCQDQqrNuotrtQjVC7w1OsUDwbg",
|
||||
session_key:
|
||||
"AQAAAADaCbP2gdOy8jrhikjploKgSBaFSJ5rvHcziaADbwNEzeCSrfuAUlXvCvxik8kU+MfCHIi5arN2M7UM5rGKdzkHnkReoIByFkeMdbjKWk5SFpVQexcM74eDhBGj+ICkQqOgApfnEbSswrmreB0+MhHHyLStwW5fy5f8A9QW1sbPuohkBuRmj9fwd3Uh+swkA0KqzbqLa7UI1Qu8NTrFA8G4",
|
||||
sender_claimed_keys: {
|
||||
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
|
||||
},
|
||||
forwarding_curve25519_key_chain: [],
|
||||
},
|
||||
{
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
room_id: "!cLDYAnjpiQXIrSwngM:localhost:8480",
|
||||
sender_key: "C9FMqTD20C0VaGWE/aSImkimuE6HDa/RyYj5gRUg3gY",
|
||||
session_id: "P/Jy9Tog4CMtLseeS4Fe2AEXZov3k6cibcop/uyhr78",
|
||||
session_key:
|
||||
"AQAAAAATyAVm0c9c9DW9Od72MxvfSDYoysBw3C6yMJ3bYuTmssHN7yNGm59KCtKeFp2Y5qO7lvUmwOfSTvTASUb7HViE7Lt+Bvp5WiMTJ2Pv6m+N12ihyowV5lgtKFWI18Wxd0AugMTVQRwjBK6aMobf86NXWD2hiKm3N6kWbC0PXmqV7T/ycvU6IOAjLS7HnkuBXtgBF2aL95OnIm3KKf7soa+/",
|
||||
sender_claimed_keys: {
|
||||
ed25519: "RSq0Xw0RR0DeqlJ/j3qrF5qbN0D96fKk8lz9kZJlG9k",
|
||||
},
|
||||
forwarding_curve25519_key_chain: [],
|
||||
},
|
||||
];
|
||||
let importTotal = 0;
|
||||
const opt: ImportRoomKeysOpts = {
|
||||
progressCallback: (stage) => {
|
||||
importTotal = stage.total;
|
||||
},
|
||||
};
|
||||
await rustCrypto.importRoomKeys(someRoomKeys, opt);
|
||||
|
||||
expect(importTotal).toBe(2);
|
||||
|
||||
const keys = await rustCrypto.exportRoomKeys();
|
||||
expect(Array.isArray(keys)).toBeTruthy();
|
||||
expect(keys.length).toBe(2);
|
||||
|
||||
const aSession = someRoomKeys[0];
|
||||
|
||||
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
|
||||
|
||||
expect(aSession).toStrictEqual(exportedKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,19 +137,20 @@ describe("RustCrypto", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("isCrossSigningReady", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("getCrossSigningKeyId", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it("bootstrapCrossSigning", async () => {
|
||||
it("bootstrapCrossSigning delegates to CrossSigningIdentity", async () => {
|
||||
const rustCrypto = await makeTestRustCrypto();
|
||||
const mockCrossSigningIdentity = {
|
||||
bootstrapCrossSigning: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// @ts-ignore private property
|
||||
rustCrypto.crossSigningIdentity = mockCrossSigningIdentity;
|
||||
await rustCrypto.bootstrapCrossSigning({});
|
||||
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("isSecretStorageReady", async () => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { secretStorageContainsCrossSigningKeys } from "../../../src/rust-crypto/secret-storage";
|
||||
import { ServerSideSecretStorage } from "../../../src/secret-storage";
|
||||
|
||||
describe("secret-storage", () => {
|
||||
describe("secretStorageContainsCrossSigningKeys", () => {
|
||||
it("should return false when the master cross-signing key is not stored in secret storage", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockReturnValue(false),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return false when there is no shared secret storage key between master, user signing and self signing keys", async () => {
|
||||
const secretStorage = {
|
||||
isStored: (type: string) => {
|
||||
// Return different storage keys
|
||||
if (type === "m.cross_signing.master") return { secretStorageKey: {} };
|
||||
else return { secretStorageKey2: {} };
|
||||
},
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return false when the secret storage key for the master key is only shared by the user-signing key", async () => {
|
||||
const secretStorage = {
|
||||
isStored: (type: string) => {
|
||||
// Return different storage keys
|
||||
if (type === "m.cross_signing.master" || type === "m.cross_signing.user_signing") {
|
||||
return { secretStorageKey: {} };
|
||||
} else {
|
||||
return { secretStorageKey2: {} };
|
||||
}
|
||||
},
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return true when there is shared secret storage key between master, user signing and self signing keys", async () => {
|
||||
const secretStorage = {
|
||||
isStored: jest.fn().mockReturnValue({ secretStorageKey: {} }),
|
||||
} as unknown as ServerSideSecretStorage;
|
||||
|
||||
const result = await secretStorageContainsCrossSigningKeys(secretStorage);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -254,4 +254,31 @@ describe("IndexedDBStore", () => {
|
||||
});
|
||||
await expect(store.startup()).rejects.toThrow("Test");
|
||||
});
|
||||
|
||||
it("remote worker should terminate upon destroy call", async () => {
|
||||
const terminate = jest.fn();
|
||||
const worker = new (class MockWorker {
|
||||
private onmessage!: (data: any) => void;
|
||||
postMessage(data: any) {
|
||||
this.onmessage({
|
||||
data: {
|
||||
command: "cmd_success",
|
||||
seq: data.seq,
|
||||
result: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
public terminate = terminate;
|
||||
})() as unknown as Worker;
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
indexedDB: indexedDB,
|
||||
dbName: "database",
|
||||
localStorage,
|
||||
workerFactory: () => worker,
|
||||
});
|
||||
await store.startup();
|
||||
await expect(store.destroy()).resolves;
|
||||
expect(terminate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
sortEventsByLatestContentTimestamp,
|
||||
safeSet,
|
||||
MapWithDefault,
|
||||
globToRegexp,
|
||||
escapeRegExp,
|
||||
} from "../../src/utils";
|
||||
import { logger } from "../../src/logger";
|
||||
import { mkMessage } from "../test-utils/test-utils";
|
||||
@@ -725,4 +727,19 @@ describe("utils", function () {
|
||||
await utils.immediate();
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeRegExp", () => {
|
||||
it("should escape XYZ", () => {
|
||||
expect(escapeRegExp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]")).toMatchInlineSnapshot(
|
||||
`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globToRegexp", () => {
|
||||
it("should not explode when given regexes as globs", () => {
|
||||
const result = globToRegexp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]");
|
||||
expect(result).toMatchInlineSnapshot(`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1054,14 +1054,7 @@ describe("Call", function () {
|
||||
|
||||
mockSendEvent.mockReset();
|
||||
|
||||
let caught = false;
|
||||
try {
|
||||
call.reject();
|
||||
} catch (e) {
|
||||
caught = true;
|
||||
}
|
||||
|
||||
expect(caught).toEqual(true);
|
||||
expect(() => call.reject()).toThrow();
|
||||
expect(client.client.sendEvent).not.toHaveBeenCalled();
|
||||
|
||||
call.hangup(CallErrorCode.UserHangup, true);
|
||||
|
||||
@@ -18,25 +18,25 @@ import { mocked } from "jest-mock";
|
||||
|
||||
import { EventType, GroupCallIntent, GroupCallType, MatrixCall, MatrixEvent, Room, RoomMember } from "../../../src";
|
||||
import { RoomStateEvent } from "../../../src/models/room-state";
|
||||
import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall";
|
||||
import { GroupCall, GroupCallEvent, GroupCallState, GroupCallStatsReportEvent } from "../../../src/webrtc/groupCall";
|
||||
import { IMyDevice, MatrixClient } from "../../../src/client";
|
||||
import {
|
||||
FAKE_CONF_ID,
|
||||
FAKE_DEVICE_ID_1,
|
||||
FAKE_DEVICE_ID_2,
|
||||
FAKE_ROOM_ID,
|
||||
FAKE_SESSION_ID_1,
|
||||
FAKE_SESSION_ID_2,
|
||||
FAKE_USER_ID_1,
|
||||
FAKE_USER_ID_2,
|
||||
FAKE_USER_ID_3,
|
||||
installWebRTCMocks,
|
||||
MockCallFeed,
|
||||
MockCallMatrixClient,
|
||||
MockMatrixCall,
|
||||
MockMediaStream,
|
||||
MockMediaStreamTrack,
|
||||
MockRTCPeerConnection,
|
||||
MockMatrixCall,
|
||||
FAKE_ROOM_ID,
|
||||
FAKE_USER_ID_1,
|
||||
FAKE_CONF_ID,
|
||||
FAKE_DEVICE_ID_2,
|
||||
FAKE_SESSION_ID_2,
|
||||
FAKE_USER_ID_2,
|
||||
FAKE_DEVICE_ID_1,
|
||||
FAKE_SESSION_ID_1,
|
||||
FAKE_USER_ID_3,
|
||||
} from "../../test-utils/webrtc";
|
||||
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from "../../../src/webrtc/callEventTypes";
|
||||
import { sleep } from "../../../src/utils";
|
||||
@@ -44,6 +44,9 @@ import { CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
|
||||
import { CallFeed } from "../../../src/webrtc/callFeed";
|
||||
import { CallEvent, CallState } from "../../../src/webrtc/call";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import { CallFeedReport } from "../../../src/webrtc/stats/statsReport";
|
||||
import { CallFeedStatsReporter } from "../../../src/webrtc/stats/callFeedStatsReporter";
|
||||
import { StatsReportEmitter } from "../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
const FAKE_STATE_EVENTS = [
|
||||
{
|
||||
@@ -186,10 +189,7 @@ describe("Group Call", function () {
|
||||
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
|
||||
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValue("Error");
|
||||
|
||||
try {
|
||||
await groupCall.initLocalCallFeed();
|
||||
} catch (e) {}
|
||||
|
||||
await expect(groupCall.initLocalCallFeed()).rejects.toBeTruthy();
|
||||
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
|
||||
});
|
||||
|
||||
@@ -1729,4 +1729,89 @@ describe("Group Call", function () {
|
||||
expect(start).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("as stats event listener and a CallFeedReport was triggered", () => {
|
||||
let groupCall: GroupCall;
|
||||
let reportEmitter: StatsReportEmitter;
|
||||
const report: CallFeedReport = {} as CallFeedReport;
|
||||
beforeEach(async () => {
|
||||
CallFeedStatsReporter.expandCallFeedReport = jest.fn().mockReturnValue(report);
|
||||
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
|
||||
const mockClient = typedMockClient.typed();
|
||||
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
|
||||
room.currentState.members[FAKE_USER_ID_1] = {
|
||||
userId: FAKE_USER_ID_1,
|
||||
membership: "join",
|
||||
} as unknown as RoomMember;
|
||||
room.currentState.members[FAKE_USER_ID_2] = {
|
||||
userId: FAKE_USER_ID_2,
|
||||
membership: "join",
|
||||
} as unknown as RoomMember;
|
||||
room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents());
|
||||
groupCall = await createAndEnterGroupCall(mockClient, room);
|
||||
reportEmitter = groupCall.getGroupCallStats().reports;
|
||||
});
|
||||
|
||||
it("should not extends with feed stats if no call exists", async () => {
|
||||
const testPromise = new Promise<void>((done) => {
|
||||
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith({}, [], "from-call-feed");
|
||||
done();
|
||||
});
|
||||
});
|
||||
const report: CallFeedReport = {} as CallFeedReport;
|
||||
reportEmitter.emitCallFeedReport(report);
|
||||
await testPromise;
|
||||
});
|
||||
|
||||
it("and a CallFeedReport was triggered then it should extends with local feed", async () => {
|
||||
const localCallFeed = {} as CallFeed;
|
||||
groupCall.localCallFeed = localCallFeed;
|
||||
|
||||
const testPromise = new Promise<void>((done) => {
|
||||
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
[localCallFeed],
|
||||
"from-local-feed",
|
||||
);
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
[],
|
||||
"from-call-feed",
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
const report: CallFeedReport = {} as CallFeedReport;
|
||||
reportEmitter.emitCallFeedReport(report);
|
||||
await testPromise;
|
||||
});
|
||||
|
||||
it("and a CallFeedReport was triggered then it should extends with remote feed", async () => {
|
||||
const localCallFeed = {} as CallFeed;
|
||||
groupCall.localCallFeed = localCallFeed;
|
||||
// @ts-ignore Suppress error because access to private property
|
||||
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
|
||||
report.callId = call.callId;
|
||||
const feeds = call.getFeeds();
|
||||
const testPromise = new Promise<void>((done) => {
|
||||
groupCall.on(GroupCallStatsReportEvent.CallFeedStats, () => {
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
[localCallFeed],
|
||||
"from-local-feed",
|
||||
);
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport).toHaveBeenCalledWith(
|
||||
report,
|
||||
feeds,
|
||||
"from-call-feed",
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
reportEmitter.emitCallFeedReport(report);
|
||||
await testPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CallFeedStatsReporter should builds CallFeedReport 1`] = `
|
||||
{
|
||||
"callFeeds": [],
|
||||
"callId": "CALL_ID",
|
||||
"opponentMemberId": "USER_ID",
|
||||
"transceiver": [
|
||||
{
|
||||
"currentDirection": "sendonly",
|
||||
"direction": "sendrecv",
|
||||
"mid": "0",
|
||||
"receiver": {
|
||||
"constrainDeviceId": "constrainDeviceId-receiver_audio_0",
|
||||
"enabled": true,
|
||||
"id": "receiver_audio_0",
|
||||
"kind": "audio",
|
||||
"label": "receiver",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-receiver_audio_0",
|
||||
},
|
||||
"sender": {
|
||||
"constrainDeviceId": "constrainDeviceId-sender_audio_0",
|
||||
"enabled": true,
|
||||
"id": "sender_audio_0",
|
||||
"kind": "audio",
|
||||
"label": "sender",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-sender_audio_0",
|
||||
},
|
||||
},
|
||||
{
|
||||
"currentDirection": "sendrecv",
|
||||
"direction": "recvonly",
|
||||
"mid": "1",
|
||||
"receiver": {
|
||||
"constrainDeviceId": "constrainDeviceId-receiver_video_1",
|
||||
"enabled": true,
|
||||
"id": "receiver_video_1",
|
||||
"kind": "video",
|
||||
"label": "receiver",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-receiver_video_1",
|
||||
},
|
||||
"sender": {
|
||||
"constrainDeviceId": "constrainDeviceId-sender_video_1",
|
||||
"enabled": true,
|
||||
"id": "sender_video_1",
|
||||
"kind": "video",
|
||||
"label": "sender",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-sender_video_1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"currentDirection": "recvonly",
|
||||
"direction": "recvonly",
|
||||
"mid": "2",
|
||||
"receiver": {
|
||||
"constrainDeviceId": "constrainDeviceId-receiver_video_2",
|
||||
"enabled": true,
|
||||
"id": "receiver_video_2",
|
||||
"kind": "video",
|
||||
"label": "receiver",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-receiver_video_2",
|
||||
},
|
||||
"sender": null,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`CallFeedStatsReporter should extends CallFeedReport with call feeds 1`] = `
|
||||
[
|
||||
{
|
||||
"audio": {
|
||||
"constrainDeviceId": "constrainDeviceId-video-1",
|
||||
"enabled": true,
|
||||
"id": "video-1",
|
||||
"kind": "video",
|
||||
"label": "--",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-video-1",
|
||||
},
|
||||
"isAudioMuted": true,
|
||||
"isVideoMuted": false,
|
||||
"prefix": "unknown",
|
||||
"purpose": undefined,
|
||||
"stream": "stream-1",
|
||||
"type": "local",
|
||||
"video": {
|
||||
"constrainDeviceId": "constrainDeviceId-audio-1",
|
||||
"enabled": true,
|
||||
"id": "audio-1",
|
||||
"kind": "audio",
|
||||
"label": "--",
|
||||
"muted": false,
|
||||
"readyState": "live",
|
||||
"settingDeviceId": "settingDeviceId-audio-1",
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { CallFeedStatsReporter } from "../../../../src/webrtc/stats/callFeedStatsReporter";
|
||||
import { CallFeedReport } from "../../../../src/webrtc/stats/statsReport";
|
||||
import { CallFeed } from "../../../../src/webrtc/callFeed";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
describe("CallFeedStatsReporter", () => {
|
||||
let rtcSpy: RTCPeerConnection;
|
||||
beforeEach(() => {
|
||||
rtcSpy = {} as RTCPeerConnection;
|
||||
rtcSpy.getTransceivers = jest.fn().mockReturnValue(buildTransceiverMocks());
|
||||
});
|
||||
|
||||
describe("should", () => {
|
||||
it("builds CallFeedReport", async () => {
|
||||
expect(CallFeedStatsReporter.buildCallFeedReport(CALL_ID, USER_ID, rtcSpy)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("extends CallFeedReport with call feeds", async () => {
|
||||
const feed = buildCallFeedMock("1");
|
||||
const callFeedList: CallFeed[] = [feed];
|
||||
const report = {
|
||||
callId: "callId",
|
||||
opponentMemberId: "opponentMemberId",
|
||||
transceiver: [],
|
||||
callFeeds: [],
|
||||
} as CallFeedReport;
|
||||
|
||||
expect(CallFeedStatsReporter.expandCallFeedReport(report, callFeedList).callFeeds).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
const buildTransceiverMocks = (): RTCRtpTransceiver[] => {
|
||||
const trans1 = {
|
||||
mid: "0",
|
||||
direction: "sendrecv",
|
||||
currentDirection: "sendonly",
|
||||
sender: buildSenderMock("sender_audio_0", "audio"),
|
||||
receiver: buildReceiverMock("receiver_audio_0", "audio"),
|
||||
} as RTCRtpTransceiver;
|
||||
const trans2 = {
|
||||
mid: "1",
|
||||
direction: "recvonly",
|
||||
currentDirection: "sendrecv",
|
||||
sender: buildSenderMock("sender_video_1", "video"),
|
||||
receiver: buildReceiverMock("receiver_video_1", "video"),
|
||||
} as RTCRtpTransceiver;
|
||||
const trans3 = {
|
||||
mid: "2",
|
||||
direction: "recvonly",
|
||||
currentDirection: "recvonly",
|
||||
sender: { track: null } as RTCRtpSender,
|
||||
receiver: buildReceiverMock("receiver_video_2", "video"),
|
||||
} as RTCRtpTransceiver;
|
||||
return [trans1, trans2, trans3];
|
||||
};
|
||||
|
||||
const buildSenderMock = (id: string, kind: "audio" | "video"): RTCRtpSender => {
|
||||
const track = buildTrackMock(id, kind);
|
||||
return {
|
||||
track,
|
||||
} as RTCRtpSender;
|
||||
};
|
||||
|
||||
const buildReceiverMock = (id: string, kind: "audio" | "video"): RTCRtpReceiver => {
|
||||
const track = buildTrackMock(id, kind);
|
||||
return {
|
||||
track,
|
||||
} as RTCRtpReceiver;
|
||||
};
|
||||
|
||||
const buildTrackMock = (id: string, kind: "audio" | "video"): MediaStreamTrack => {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
enabled: true,
|
||||
label: "--",
|
||||
muted: false,
|
||||
readyState: "live",
|
||||
getSettings: () => ({ deviceId: `settingDeviceId-${id}` }),
|
||||
getConstraints: () => ({ deviceId: `constrainDeviceId-${id}` }),
|
||||
} as MediaStreamTrack;
|
||||
};
|
||||
|
||||
const buildCallFeedMock = (id: string, isLocal = true): CallFeed => {
|
||||
const stream = {
|
||||
id: `stream-${id}`,
|
||||
getAudioTracks(): MediaStreamTrack[] {
|
||||
return [buildTrackMock(`video-${id}`, "video")];
|
||||
},
|
||||
getVideoTracks(): MediaStreamTrack[] {
|
||||
return [buildTrackMock(`audio-${id}`, "audio")];
|
||||
},
|
||||
} as MediaStream;
|
||||
return {
|
||||
stream,
|
||||
isLocal: () => isLocal,
|
||||
isVideoMuted: () => false,
|
||||
isAudioMuted: () => true,
|
||||
} as CallFeed;
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { CallStatsReportGatherer } from "../../../../src/webrtc/stats/callStatsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import { MediaSsrcHandler } from "../../../../src/webrtc/stats/media/mediaSsrcHandler";
|
||||
import { currentChromeReport, prevChromeReport } from "../../../test-utils/webrtcReports";
|
||||
import { MockRTCPeerConnection } from "../../../test-utils/webrtc";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
|
||||
describe("CallStatsReportGatherer", () => {
|
||||
let collector: CallStatsReportGatherer;
|
||||
let rtcSpy: RTCPeerConnection;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
|
||||
rtcSpy.addEventListener = jest.fn();
|
||||
rtcSpy.getTransceivers = jest.fn().mockReturnValue([]);
|
||||
emitter = new StatsReportEmitter();
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
|
||||
describe("on process stats", () => {
|
||||
it("if active calculate stats reports", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("if not active do not calculate stats reports", async () => {
|
||||
collector.setActive(false);
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("if get reports fails, the collector becomes inactive", async () => {
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
getStats.mockRejectedValue(new Error("unknown"));
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("if active and getStats returns not an RTCStatsReport inside a promise the collector fails and becomes inactive", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
// @ts-ignore
|
||||
getStats.mockReturnValue({});
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual({
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("if active and the collector runs not the first time the Summery Stats is marked as not fits collection", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
// @ts-ignore
|
||||
collector.previousStatsReport = {} as RTCStatsReport;
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("should not only produce a call summary stat but also", () => {
|
||||
const wantedSummaryReport = {
|
||||
isFirstCollection: false,
|
||||
audioTrackSummary: {
|
||||
concealedAudio: 0,
|
||||
count: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
muted: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
receivedAudioMedia: 0,
|
||||
receivedMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
videoTrackSummary: {
|
||||
concealedAudio: 0,
|
||||
count: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
muted: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
rtcSpy = new MockRTCPeerConnection() as unknown as RTCPeerConnection;
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
|
||||
const previous = prevChromeReport as unknown as RTCStatsReport;
|
||||
previous.get = (id: string) => {
|
||||
return prevChromeReport.find((data) => data.id === id);
|
||||
};
|
||||
// @ts-ignore
|
||||
collector.previousStatsReport = previous;
|
||||
|
||||
const current = currentChromeReport as unknown as RTCStatsReport;
|
||||
current.get = (id: string) => {
|
||||
return currentChromeReport.find((data) => data.id === id);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
getStats.mockResolvedValue(current);
|
||||
});
|
||||
|
||||
it("emit byteSentStatsReport", async () => {
|
||||
const emitByteSendReport = jest.spyOn(emitter, "emitByteSendReport");
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual(wantedSummaryReport);
|
||||
expect(emitByteSendReport).toHaveBeenCalled();
|
||||
});
|
||||
it("emit emitConnectionStatsReport", async () => {
|
||||
const emitConnectionStatsReport = jest.spyOn(emitter, "emitConnectionStatsReport");
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual(wantedSummaryReport);
|
||||
expect(emitConnectionStatsReport).toHaveBeenCalled();
|
||||
});
|
||||
it("emit callFeedStatsReport", async () => {
|
||||
const emitCallFeedReport = jest.spyOn(emitter, "emitCallFeedReport");
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual(wantedSummaryReport);
|
||||
expect(emitCallFeedReport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on signal state change event", () => {
|
||||
let events: { [key: string]: any };
|
||||
beforeEach(() => {
|
||||
events = [];
|
||||
// Define the addEventListener method with a Jest mock function
|
||||
rtcSpy.addEventListener = jest.fn((event: any, callback: any) => {
|
||||
events[event] = callback;
|
||||
});
|
||||
|
||||
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
it("in case of stable, parse remote and local description", () => {
|
||||
// @ts-ignore
|
||||
const mediaSsrcHandler = {
|
||||
parse: jest.fn(),
|
||||
ssrcToMid: jest.fn(),
|
||||
findMidBySsrc: jest.fn(),
|
||||
getSsrcToMidMap: jest.fn(),
|
||||
} as MediaSsrcHandler;
|
||||
|
||||
const remoteSDP = "sdp";
|
||||
const localSDP = "sdp";
|
||||
|
||||
// @ts-ignore
|
||||
rtcSpy.signalingState = "stable";
|
||||
|
||||
// @ts-ignore
|
||||
rtcSpy.currentRemoteDescription = <RTCSessionDescription>{ sdp: remoteSDP };
|
||||
// @ts-ignore
|
||||
rtcSpy.currentLocalDescription = <RTCSessionDescription>{ sdp: localSDP };
|
||||
|
||||
// @ts-ignore
|
||||
collector.trackStats.mediaSsrcHandler = mediaSsrcHandler;
|
||||
|
||||
events["signalingstatechange"]();
|
||||
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(remoteSDP, "remote");
|
||||
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(localSDP, "local");
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { TrackID } from "../../../../src/webrtc/stats/statsReport";
|
||||
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
|
||||
import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder";
|
||||
import { ConnectionStatsReportBuilder } from "../../../../src/webrtc/stats/connectionStatsReportBuilder";
|
||||
|
||||
describe("StatsReportBuilder", () => {
|
||||
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
|
||||
@@ -39,7 +39,7 @@ describe("StatsReportBuilder", () => {
|
||||
|
||||
describe("should build stats", () => {
|
||||
it("by media track stats.", async () => {
|
||||
expect(StatsReportBuilder.build(stats)).toEqual({
|
||||
expect(ConnectionStatsReportBuilder.build(stats)).toEqual({
|
||||
bitrate: {
|
||||
audio: {
|
||||
download: 4000,
|
||||
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter";
|
||||
import { ConnectionStatsBuilder } from "../../../../src/webrtc/stats/connectionStatsBuilder";
|
||||
|
||||
describe("ConnectionStatsReporter", () => {
|
||||
describe("should on bandwidth stats", () => {
|
||||
@@ -22,11 +22,11 @@ describe("ConnectionStatsReporter", () => {
|
||||
availableIncomingBitrate: 1000,
|
||||
availableOutgoingBitrate: 2000,
|
||||
} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
});
|
||||
it("build empty bandwidth report if chromium starts attributes not available", () => {
|
||||
const stats = {} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,11 +36,11 @@ describe("ConnectionStatsReporter", () => {
|
||||
availableIncomingBitrate: 1000,
|
||||
availableOutgoingBitrate: 2000,
|
||||
} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
|
||||
});
|
||||
it("build empty bandwidth report if chromium starts attributes not available", () => {
|
||||
const stats = {} as RTCIceCandidatePairStats;
|
||||
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
|
||||
import { SummaryStats } from "../../../../src/webrtc/stats/summaryStats";
|
||||
import { CallStatsReportSummary } from "../../../../src/webrtc/stats/callStatsReportSummary";
|
||||
|
||||
const GROUP_CALL_ID = "GROUP_ID";
|
||||
const LOCAL_USER_ID = "LOCAL_USER_ID";
|
||||
@@ -92,12 +92,27 @@ describe("GroupCallStats", () => {
|
||||
const collector = stats.getStatsReportGatherer("CALL_ID");
|
||||
stats.reports.emitSummaryStatsReport = jest.fn();
|
||||
const summaryStats = {
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: { count: 0, muted: 0 },
|
||||
videoTrackSummary: { count: 0, muted: 0 },
|
||||
} as SummaryStats;
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
} as CallStatsReportSummary;
|
||||
let processStatsSpy;
|
||||
if (collector) {
|
||||
processStatsSpy = jest.spyOn(collector, "processStats").mockResolvedValue(summaryStats);
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
CallFeedReport,
|
||||
ConnectionStatsReport,
|
||||
StatsReport,
|
||||
SummaryStatsReport,
|
||||
@@ -62,4 +63,16 @@ describe("StatsReportEmitter", () => {
|
||||
emitter.emitSummaryStatsReport(report);
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit and receive CallFeedReports", async () => {
|
||||
const report = {} as CallFeedReport;
|
||||
return new Promise((resolve, _) => {
|
||||
emitter.on(StatsReport.CALL_FEED_REPORT, (r) => {
|
||||
expect(r).toBe(report);
|
||||
resolve(null);
|
||||
return;
|
||||
});
|
||||
emitter.emitCallFeedReport(report);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
|
||||
const CALL_ID = "CALL_ID";
|
||||
const USER_ID = "USER_ID";
|
||||
|
||||
describe("StatsReportGatherer", () => {
|
||||
let collector: StatsReportGatherer;
|
||||
let rtcSpy: RTCPeerConnection;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
|
||||
rtcSpy.addEventListener = jest.fn();
|
||||
emitter = new StatsReportEmitter();
|
||||
collector = new StatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
|
||||
});
|
||||
|
||||
describe("on process stats", () => {
|
||||
it("if active calculate stats reports", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
const report = {} as RTCStatsReport;
|
||||
report.forEach = jest.fn().mockReturnValue([]);
|
||||
getStats.mockResolvedValue(report);
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(actual).toEqual({
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("if not active do not calculate stats reports", async () => {
|
||||
collector.setActive(false);
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("if get reports fails, the collector becomes inactive", async () => {
|
||||
expect(collector.getActive()).toBeTruthy();
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
getStats.mockRejectedValue(new Error("unknown"));
|
||||
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("if active an RTCStatsReport not a promise the collector becomes inactive", async () => {
|
||||
const getStats = jest.spyOn(rtcSpy, "getStats");
|
||||
// @ts-ignore
|
||||
getStats.mockReturnValue({});
|
||||
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
|
||||
expect(actual).toEqual({
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 0,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
});
|
||||
expect(getStats).toHaveBeenCalled();
|
||||
expect(collector.getActive()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+259
-4
@@ -13,16 +13,17 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { SummaryStatsReporter } from "../../../../src/webrtc/stats/summaryStatsReporter";
|
||||
import { SummaryStatsReportGatherer } from "../../../../src/webrtc/stats/summaryStatsReportGatherer";
|
||||
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
|
||||
import { groupCallParticipantsFourOtherDevices } from "../../../test-utils/webrtc";
|
||||
|
||||
describe("SummaryStatsReporter", () => {
|
||||
let reporter: SummaryStatsReporter;
|
||||
describe("SummaryStatsReportGatherer", () => {
|
||||
let reporter: SummaryStatsReportGatherer;
|
||||
let emitter: StatsReportEmitter;
|
||||
beforeEach(() => {
|
||||
emitter = new StatsReportEmitter();
|
||||
emitter.emitSummaryStatsReport = jest.fn();
|
||||
reporter = new SummaryStatsReporter(emitter);
|
||||
reporter = new SummaryStatsReportGatherer(emitter);
|
||||
});
|
||||
|
||||
describe("build Summary Stats Report", () => {
|
||||
@@ -30,10 +31,38 @@ describe("SummaryStatsReporter", () => {
|
||||
reporter.build([]);
|
||||
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should do nothing if a summary stats element collection the is first time", async () => {
|
||||
reporter.build([
|
||||
{
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 4,
|
||||
receivedVideoMedia: 6,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 100,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger new summary report", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 4,
|
||||
receivedVideoMedia: 6,
|
||||
@@ -55,6 +84,7 @@ describe("SummaryStatsReporter", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 13,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 13,
|
||||
@@ -76,6 +106,7 @@ describe("SummaryStatsReporter", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
@@ -97,6 +128,7 @@ describe("SummaryStatsReporter", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 15,
|
||||
receivedAudioMedia: 6,
|
||||
receivedVideoMedia: 9,
|
||||
@@ -133,6 +165,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("as received video Media, although video was not received, but because video muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
@@ -169,6 +202,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("as received no video Media, because only on video was muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 10,
|
||||
receivedVideoMedia: 0,
|
||||
@@ -205,6 +239,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("as received no audio Media, although audio not received and audio muted", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 100,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 100,
|
||||
@@ -241,6 +276,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("should find max jitter and max packet loss", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
@@ -262,6 +298,7 @@ describe("SummaryStatsReporter", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
@@ -283,6 +320,7 @@ describe("SummaryStatsReporter", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
@@ -304,6 +342,7 @@ describe("SummaryStatsReporter", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
@@ -340,6 +379,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("as received video Media, if no audio track received should count as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 10,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 10,
|
||||
@@ -376,6 +416,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("as received audio Media, if no video track received should count as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 22,
|
||||
receivedVideoMedia: 0,
|
||||
@@ -412,6 +453,7 @@ describe("SummaryStatsReporter", () => {
|
||||
it("as received no media at all, as received Media", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 0,
|
||||
receivedAudioMedia: 0,
|
||||
receivedVideoMedia: 0,
|
||||
@@ -444,5 +486,218 @@ describe("SummaryStatsReporter", () => {
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter the first time summery stats", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 40,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
it("should report missing peer connections", async () => {
|
||||
const summary = [
|
||||
{
|
||||
isFirstCollection: true,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 20,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 0,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
isFirstCollection: false,
|
||||
receivedMedia: 1,
|
||||
receivedAudioMedia: 1,
|
||||
receivedVideoMedia: 1,
|
||||
audioTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 5,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
videoTrackSummary: {
|
||||
count: 1,
|
||||
muted: 0,
|
||||
maxJitter: 0,
|
||||
maxPacketLoss: 40,
|
||||
concealedAudio: 0,
|
||||
totalAudio: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
reporter.build(summary);
|
||||
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 2,
|
||||
percentageConcealedAudio: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("extend Summary Stats Report", () => {
|
||||
it("should extend the report with the appropriate data based on a user map", async () => {
|
||||
const summary = {
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
};
|
||||
SummaryStatsReportGatherer.extendSummaryReport(summary, groupCallParticipantsFourOtherDevices);
|
||||
expect(summary).toStrictEqual({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
opponentUsersInCall: 1,
|
||||
opponentDevicesInCall: 4,
|
||||
diffDevicesToPeerConnections: 0,
|
||||
ratioPeerConnectionToDevices: 1,
|
||||
});
|
||||
});
|
||||
it("should extend the report data based on a user map", async () => {
|
||||
const summary = {
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
};
|
||||
SummaryStatsReportGatherer.extendSummaryReport(summary, new Map());
|
||||
expect(summary).toStrictEqual({
|
||||
percentageReceivedMedia: 1,
|
||||
percentageReceivedAudioMedia: 1,
|
||||
percentageReceivedVideoMedia: 1,
|
||||
maxJitter: 2,
|
||||
maxPacketLoss: 40,
|
||||
peerConnections: 4,
|
||||
percentageConcealedAudio: 0,
|
||||
opponentUsersInCall: 0,
|
||||
opponentDevicesInCall: 0,
|
||||
diffDevicesToPeerConnections: -4,
|
||||
ratioPeerConnectionToDevices: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-25
@@ -13,20 +13,20 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter";
|
||||
import { TrackStatsBuilder } from "../../../../src/webrtc/stats/trackStatsBuilder";
|
||||
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
|
||||
|
||||
describe("TrackStatsReporter", () => {
|
||||
describe("TrackStatsBuilder", () => {
|
||||
describe("should on frame and resolution stats", () => {
|
||||
it("creating empty frame and resolution report, if no data available.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.buildFramerateResolution(trackStats, {});
|
||||
TrackStatsBuilder.buildFramerateResolution(trackStats, {});
|
||||
expect(trackStats.getFramerate()).toEqual(0);
|
||||
expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 });
|
||||
});
|
||||
it("creating empty frame and resolution report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildFramerateResolution(trackStats, {
|
||||
TrackStatsBuilder.buildFramerateResolution(trackStats, {
|
||||
framesPerSecond: 22.2,
|
||||
frameHeight: 180,
|
||||
frameWidth: 360,
|
||||
@@ -39,7 +39,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on simulcast", () => {
|
||||
it("creating simulcast framerate.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.calculateSimulcastFramerate(
|
||||
TrackStatsBuilder.calculateSimulcastFramerate(
|
||||
trackStats,
|
||||
{
|
||||
framesSent: 100,
|
||||
@@ -58,7 +58,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on bytes received stats", () => {
|
||||
it("creating build bitrate received report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildBitrateReceived(
|
||||
TrackStatsBuilder.buildBitrateReceived(
|
||||
trackStats,
|
||||
{
|
||||
bytesReceived: 2001000,
|
||||
@@ -73,7 +73,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on bytes send stats", () => {
|
||||
it("creating build bitrate send report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.buildBitrateSend(
|
||||
TrackStatsBuilder.buildBitrateSend(
|
||||
trackStats,
|
||||
{
|
||||
bytesSent: 2001000,
|
||||
@@ -90,7 +90,7 @@ describe("TrackStatsReporter", () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
const remote = {} as RTCStatsReport;
|
||||
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
|
||||
TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" });
|
||||
TrackStatsBuilder.buildCodec(remote, trackStats, { codecId: "codecID" });
|
||||
expect(trackStats.getCodec()).toEqual("v8");
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should on package lost stats", () => {
|
||||
it("creating build package lost on send report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "local", "video");
|
||||
TrackStatsReporter.buildPacketsLost(
|
||||
TrackStatsBuilder.buildPacketsLost(
|
||||
trackStats,
|
||||
{
|
||||
type: "outbound-rtp",
|
||||
@@ -114,7 +114,7 @@ describe("TrackStatsReporter", () => {
|
||||
});
|
||||
it("creating build package lost on received report.", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildPacketsLost(
|
||||
TrackStatsBuilder.buildPacketsLost(
|
||||
trackStats,
|
||||
{
|
||||
type: "inbound-rtp",
|
||||
@@ -133,7 +133,7 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should set state of a TrackStats", () => {
|
||||
it("to not alive if Transceiver undefined", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, undefined);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, undefined);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpSender,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpSender,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -211,7 +211,7 @@ describe("TrackStatsReporter", () => {
|
||||
} as RTCRtpReceiver,
|
||||
} as RTCRtpTransceiver;
|
||||
|
||||
TrackStatsReporter.setTrackStatsState(trackStats, ts);
|
||||
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
|
||||
expect(trackStats.alive).toBeTruthy();
|
||||
expect(trackStats.muted).toBeTruthy();
|
||||
});
|
||||
@@ -219,7 +219,7 @@ describe("TrackStatsReporter", () => {
|
||||
|
||||
describe("should build Track Summary", () => {
|
||||
it("and returns empty summary if stats list empty", async () => {
|
||||
const summary = TrackStatsReporter.buildTrackSummary([]);
|
||||
const summary = TrackStatsBuilder.buildTrackSummary([]);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 0,
|
||||
@@ -242,7 +242,7 @@ describe("TrackStatsReporter", () => {
|
||||
|
||||
it("and returns summary if stats list not empty and ignore local summery", async () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
@@ -267,7 +267,7 @@ describe("TrackStatsReporter", () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
trackStatsList[1].muted = true;
|
||||
trackStatsList[5].muted = true;
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
@@ -292,7 +292,7 @@ describe("TrackStatsReporter", () => {
|
||||
const trackStatsList = buildMockTrackStatsList();
|
||||
trackStatsList[1].muted = true;
|
||||
trackStatsList[1].alive = false;
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
@@ -330,7 +330,7 @@ describe("TrackStatsReporter", () => {
|
||||
trackStatsList[2].setAudioConcealment(220, 2000);
|
||||
trackStatsList[5].setAudioConcealment(180, 2000);
|
||||
|
||||
const summary = TrackStatsReporter.buildTrackSummary(trackStatsList);
|
||||
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
|
||||
expect(summary).toEqual({
|
||||
audioTrackSummary: {
|
||||
count: 2,
|
||||
@@ -355,25 +355,25 @@ describe("TrackStatsReporter", () => {
|
||||
describe("should build jitter value in Track Stats", () => {
|
||||
it("and returns track stats without jitter if report not 'inbound-rtp'", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { jitter: 0.01 });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { jitter: 0.01 });
|
||||
expect(trackStats.getJitter()).toEqual(0);
|
||||
});
|
||||
|
||||
it("and returns track stats with jitter", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
|
||||
expect(trackStats.getJitter()).toEqual(10);
|
||||
});
|
||||
|
||||
it("and returns negative jitter if stats has no jitter value", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp" });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp" });
|
||||
expect(trackStats.getJitter()).toEqual(-1);
|
||||
});
|
||||
|
||||
it("and returns jitter as number", async () => {
|
||||
const trackStats = new MediaTrackStats("1", "remote", "video");
|
||||
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
|
||||
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
|
||||
expect(trackStats.getJitter()).toEqual(500);
|
||||
});
|
||||
});
|
||||
+6
-6
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter";
|
||||
import { TransportStatsBuilder } from "../../../../src/webrtc/stats/transportStatsBuilder";
|
||||
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
|
||||
|
||||
describe("TransportStatsReporter", () => {
|
||||
@@ -35,7 +35,7 @@ describe("TransportStatsReporter", () => {
|
||||
it("build new transport stats if all properties there", () => {
|
||||
const { report, stats } = mockStatsReport(isFocus, 0);
|
||||
const conferenceStatsTransport: TransportStats[] = [];
|
||||
const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus);
|
||||
const transportStats = TransportStatsBuilder.buildReport(report, stats, conferenceStatsTransport, isFocus);
|
||||
expect(transportStats).toEqual([
|
||||
{
|
||||
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
|
||||
@@ -54,8 +54,8 @@ describe("TransportStatsReporter", () => {
|
||||
const mock1 = mockStatsReport(isFocus, 0);
|
||||
const mock2 = mockStatsReport(isFocus, 1);
|
||||
let transportStats: TransportStats[] = [];
|
||||
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
expect(transportStats).toEqual([
|
||||
{
|
||||
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
|
||||
@@ -84,8 +84,8 @@ describe("TransportStatsReporter", () => {
|
||||
const mock1 = mockStatsReport(isFocus, 0);
|
||||
const mock2 = mockStatsReport(isFocus, 0);
|
||||
let transportStats: TransportStats[] = [];
|
||||
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
|
||||
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
|
||||
expect(transportStats).toEqual([
|
||||
{
|
||||
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
|
||||
+7
-7
@@ -13,16 +13,16 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter";
|
||||
import { ValueFormatter } from "../../../../src/webrtc/stats/valueFormatter";
|
||||
|
||||
describe("StatsValueFormatter", () => {
|
||||
describe("ValueFormatter", () => {
|
||||
describe("on get non negative values", () => {
|
||||
it("formatter shod return number", async () => {
|
||||
expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2);
|
||||
expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0);
|
||||
expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0);
|
||||
expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0);
|
||||
expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue("2")).toEqual(2);
|
||||
expect(ValueFormatter.getNonNegativeValue(0)).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue("-2")).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue("")).toEqual(0);
|
||||
expect(ValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
-2
@@ -168,11 +168,11 @@ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix
|
||||
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
|
||||
|
||||
/**
|
||||
* Name of the "with_relations" request property for relation based redactions.
|
||||
* Name of the request property for relation based redactions.
|
||||
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
|
||||
*/
|
||||
export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new UnstableValue(
|
||||
"with_relations",
|
||||
"with_rel_types",
|
||||
"org.matrix.msc3912.with_relations",
|
||||
);
|
||||
|
||||
@@ -235,6 +235,13 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
|
||||
"org.matrix.msc3890.local_notification_settings",
|
||||
);
|
||||
|
||||
/**
|
||||
* https://github.com/matrix-org/matrix-doc/pull/4023
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
|
||||
|
||||
export interface IEncryptedFile {
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
|
||||
@@ -48,17 +48,16 @@ export interface IJoinRoomOpts {
|
||||
export interface IRedactOpts {
|
||||
reason?: string;
|
||||
/**
|
||||
* Whether events related to the redacted event should be redacted.
|
||||
*
|
||||
* If specified, then any events which relate to the event being redacted with
|
||||
* any of the relationship types listed will also be redacted.
|
||||
* Provide a "*" list item to tell the server to redact relations of any type.
|
||||
*
|
||||
* <b>Raises an Error if the server does not support it.</b>
|
||||
* Check for server-side support before using this param with
|
||||
* <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>.
|
||||
* {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
|
||||
*/
|
||||
with_relations?: Array<RelationType | string>;
|
||||
with_rel_types?: Array<RelationType | "*">;
|
||||
}
|
||||
|
||||
export interface ISendEventResponse {
|
||||
|
||||
+78
-8
@@ -15,9 +15,15 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IClientWellKnown, IWellKnownConfig } from "./client";
|
||||
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixError, Method, timeoutSignal } from "./http-api";
|
||||
import {
|
||||
OidcDiscoveryError,
|
||||
ValidatedIssuerConfig,
|
||||
validateOIDCIssuerWellKnown,
|
||||
validateWellKnownAuthentication,
|
||||
} from "./oidc/validate";
|
||||
|
||||
// Dev note: Auto discovery is part of the spec.
|
||||
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
|
||||
@@ -42,14 +48,18 @@ enum AutoDiscoveryError {
|
||||
InvalidJson = "Invalid JSON",
|
||||
}
|
||||
|
||||
interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
|
||||
interface AutoDiscoveryState {
|
||||
state: AutoDiscoveryAction;
|
||||
error?: IWellKnownConfig["error"] | null;
|
||||
}
|
||||
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
|
||||
|
||||
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
|
||||
|
||||
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
|
||||
"m.homeserver": WellKnownConfig;
|
||||
"m.identity_server": WellKnownConfig;
|
||||
"m.authentication"?: DelegatedAuthConfig | AutoDiscoveryState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +152,7 @@ export class AutoDiscovery {
|
||||
},
|
||||
};
|
||||
|
||||
if (!wellknown || !wellknown["m.homeserver"]) {
|
||||
if (!wellknown?.["m.homeserver"]) {
|
||||
logger.error("No m.homeserver key in config");
|
||||
|
||||
clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
|
||||
@@ -170,8 +180,8 @@ export class AutoDiscovery {
|
||||
}
|
||||
|
||||
// Step 3: Make sure the homeserver URL points to a homeserver.
|
||||
const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`);
|
||||
if (!hsVersions || !hsVersions.raw?.["versions"]) {
|
||||
const hsVersions = await this.fetchWellKnownObject<IServerVersions>(`${hsUrl}/_matrix/client/versions`);
|
||||
if (!hsVersions?.raw?.["versions"]) {
|
||||
logger.error("Invalid /versions response");
|
||||
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
|
||||
|
||||
@@ -256,10 +266,67 @@ export class AutoDiscovery {
|
||||
}
|
||||
});
|
||||
|
||||
const authConfig = await this.validateDiscoveryAuthenticationConfig(wellknown);
|
||||
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
|
||||
|
||||
// Step 8: Give the config to the caller (finally)
|
||||
return Promise.resolve(clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate delegated auth configuration
|
||||
* - m.authentication config is present and valid
|
||||
* - delegated auth issuer openid-configuration is reachable
|
||||
* - delegated auth issuer openid-configuration is configured correctly for us
|
||||
* When successful, DelegatedAuthConfig will be returned with endpoints used for delegated auth
|
||||
* Any errors are caught, and AutoDiscoveryState returned with error
|
||||
* @param wellKnown - configuration object as returned
|
||||
* by the .well-known auto-discovery endpoint
|
||||
* @returns Config or failure result
|
||||
*/
|
||||
public static async validateDiscoveryAuthenticationConfig(
|
||||
wellKnown: IClientWellKnown,
|
||||
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
|
||||
try {
|
||||
const homeserverAuthenticationConfig = validateWellKnownAuthentication(wellKnown);
|
||||
|
||||
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
|
||||
homeserverAuthenticationConfig.issuer,
|
||||
)}/.well-known/openid-configuration`;
|
||||
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(issuerOpenIdConfigUrl);
|
||||
|
||||
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
|
||||
logger.error("Failed to fetch issuer openid configuration");
|
||||
throw new Error(OidcDiscoveryError.General);
|
||||
}
|
||||
|
||||
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
|
||||
|
||||
const delegatedAuthConfig: DelegatedAuthConfig = {
|
||||
state: AutoDiscoveryAction.SUCCESS,
|
||||
error: null,
|
||||
...homeserverAuthenticationConfig,
|
||||
...validatedIssuerConfig,
|
||||
};
|
||||
return delegatedAuthConfig;
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message as unknown as OidcDiscoveryError;
|
||||
const errorType = Object.values(OidcDiscoveryError).includes(errorMessage)
|
||||
? errorMessage
|
||||
: OidcDiscoveryError.General;
|
||||
|
||||
const state =
|
||||
errorType === OidcDiscoveryError.NotSupported
|
||||
? AutoDiscoveryAction.IGNORE
|
||||
: AutoDiscoveryAction.FAIL_ERROR;
|
||||
|
||||
return {
|
||||
state,
|
||||
error: errorType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to automatically discover client configuration information
|
||||
* prior to logging in. Such information includes the homeserver URL
|
||||
@@ -308,7 +375,8 @@ export class AutoDiscovery {
|
||||
|
||||
// Step 1: Actually request the .well-known JSON file and make sure it
|
||||
// at least has a homeserver definition.
|
||||
const wellknown = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
|
||||
const domainWithProtocol = domain.includes("://") ? domain : `https://${domain}`;
|
||||
const wellknown = await this.fetchWellKnownObject(`${domainWithProtocol}/.well-known/matrix/client`);
|
||||
if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) {
|
||||
logger.error("No response or error when parsing .well-known");
|
||||
if (wellknown.reason) logger.error(wellknown.reason);
|
||||
@@ -345,7 +413,7 @@ export class AutoDiscovery {
|
||||
|
||||
const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
|
||||
if (!response) return {};
|
||||
return response.raw || {};
|
||||
return response.raw ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,7 +480,9 @@ export class AutoDiscovery {
|
||||
* @returns Promise which resolves to the returned state.
|
||||
* @internal
|
||||
*/
|
||||
private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
|
||||
private static async fetchWellKnownObject<T = IWellKnownConfig>(
|
||||
url: string,
|
||||
): Promise<IWellKnownConfig<Partial<T>>> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
|
||||
+152
-98
@@ -36,7 +36,11 @@ import { StubStore } from "./store/stub";
|
||||
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
|
||||
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
|
||||
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
|
||||
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
|
||||
import {
|
||||
GroupCallEventHandler,
|
||||
GroupCallEventHandlerEvent,
|
||||
GroupCallEventHandlerEventHandlerMap,
|
||||
} from "./webrtc/groupCallEventHandler";
|
||||
import * as utils from "./utils";
|
||||
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
|
||||
import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
@@ -99,13 +103,7 @@ import { MatrixScheduler } from "./scheduler";
|
||||
import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
|
||||
import { IAuthData, IAuthDict } from "./interactive-auth";
|
||||
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
ICreateSecretStorageOpts,
|
||||
IEncryptedEventInfo,
|
||||
IImportRoomKeysOpts,
|
||||
IRecoveryKey,
|
||||
} from "./crypto/api";
|
||||
import { CrossSigningKey, ICreateSecretStorageOpts, IEncryptedEventInfo, IRecoveryKey } from "./crypto/api";
|
||||
import { EventTimelineSet } from "./models/event-timeline-set";
|
||||
import { VerificationRequest } from "./crypto/verification/request/VerificationRequest";
|
||||
import { VerificationBase as Verification } from "./crypto/verification/Base";
|
||||
@@ -180,7 +178,6 @@ import { IThreepid } from "./@types/threepids";
|
||||
import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
|
||||
import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
|
||||
import { MediaHandler } from "./webrtc/mediaHandler";
|
||||
import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
|
||||
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
|
||||
@@ -204,7 +201,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
|
||||
import { CryptoBackend } from "./common-crypto/CryptoBackend";
|
||||
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
|
||||
import { BootstrapCrossSigningOpts, CryptoApi } from "./crypto-api";
|
||||
import { BootstrapCrossSigningOpts, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
|
||||
import { DeviceInfoMap } from "./crypto/DeviceList";
|
||||
import {
|
||||
AddSecretStorageKeyOpts,
|
||||
@@ -466,11 +463,6 @@ export interface IStartClientOpts {
|
||||
* @experimental
|
||||
*/
|
||||
slidingSync?: SlidingSync;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
intentionalMentions?: boolean;
|
||||
}
|
||||
|
||||
export interface IStoredClientOpts extends IStartClientOpts {}
|
||||
@@ -497,6 +489,10 @@ export interface IMSC3882GetLoginTokenCapability extends ICapability {}
|
||||
|
||||
export const UNSTABLE_MSC3882_CAPABILITY = new UnstableValue("m.get_login_token", "org.matrix.msc3882.get_login_token");
|
||||
|
||||
export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
|
||||
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
|
||||
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
|
||||
|
||||
/**
|
||||
* A representation of the capabilities advertised by a homeserver as defined by
|
||||
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
|
||||
@@ -590,8 +586,8 @@ export interface IClientWellKnown {
|
||||
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
|
||||
}
|
||||
|
||||
export interface IWellKnownConfig {
|
||||
raw?: IClientWellKnown;
|
||||
export interface IWellKnownConfig<T = IClientWellKnown> {
|
||||
raw?: T;
|
||||
action?: AutoDiscoveryAction;
|
||||
reason?: string;
|
||||
error?: Error | string;
|
||||
@@ -2577,7 +2573,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* "master", "self_signing", or "user_signing". Defaults to "master".
|
||||
*
|
||||
* @returns the key ID
|
||||
* @deprecated prefer {@link CryptoApi#getCrossSigningKeyId}
|
||||
* @deprecated prefer {@link Crypto.CryptoApi#getCrossSigningKeyId}
|
||||
*/
|
||||
public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
|
||||
if (!this.crypto) {
|
||||
@@ -2624,7 +2620,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
|
||||
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
|
||||
*/
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
if (!this.crypto) {
|
||||
@@ -3193,14 +3189,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* Import a list of room keys previously exported by exportRoomKeys
|
||||
*
|
||||
* @param keys - a list of session export objects
|
||||
* @param opts - options object
|
||||
*
|
||||
* @returns a promise which resolves when the keys have been imported
|
||||
*
|
||||
* @deprecated Prefer {@link CryptoApi.importRoomKeys | `CryptoApi.importRoomKeys`}:
|
||||
* ```javascript
|
||||
* await client.getCrypto()?.importRoomKeys([..]);
|
||||
* ```
|
||||
*/
|
||||
public importRoomKeys(keys: IMegolmSessionData[], opts?: IImportRoomKeysOpts): Promise<void> {
|
||||
if (!this.crypto) {
|
||||
public importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
|
||||
if (!this.cryptoBackend) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.importRoomKeys(keys, opts);
|
||||
return this.cryptoBackend.importRoomKeys(keys, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3808,7 +3810,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
algorithm.free();
|
||||
}
|
||||
|
||||
await this.importRoomKeys(keys, {
|
||||
await this.getCrypto()?.importRoomKeys(keys, {
|
||||
progressCallback,
|
||||
untrusted,
|
||||
source: "backup",
|
||||
@@ -4087,27 +4089,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
queryString["server_name"] = opts.viaServers;
|
||||
}
|
||||
|
||||
try {
|
||||
const data: IJoinRequestBody = {};
|
||||
const signedInviteObj = await signPromise;
|
||||
if (signedInviteObj) {
|
||||
data.third_party_signed = signedInviteObj;
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const room = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
// return syncApi.syncRoom(room);
|
||||
}
|
||||
return room;
|
||||
} catch (e) {
|
||||
throw e; // rethrow for reject
|
||||
const data: IJoinRequestBody = {};
|
||||
const signedInviteObj = await signPromise;
|
||||
if (signedInviteObj) {
|
||||
data.third_party_signed = signedInviteObj;
|
||||
}
|
||||
|
||||
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
|
||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
|
||||
|
||||
const roomId = res.room_id;
|
||||
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
|
||||
const syncRoom = syncApi.createRoom(roomId);
|
||||
if (opts.syncRoom) {
|
||||
// v2 will do this for us
|
||||
// return syncApi.syncRoom(room);
|
||||
}
|
||||
return syncRoom;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4595,10 +4593,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* @param txnId - transaction id. One will be made up if not supplied.
|
||||
* @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
|
||||
* @param opts - Redact options
|
||||
* @returns Promise which resolves: TODO
|
||||
* @returns Rejects: with an error response.
|
||||
* @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
|
||||
* @throws Error if called with `with_rel_types` (MSC3912) but the server does not support it.
|
||||
* Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
|
||||
*/
|
||||
public redactEvent(
|
||||
@@ -4628,34 +4626,30 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
threadId = null;
|
||||
}
|
||||
const reason = opts?.reason;
|
||||
const content: IContent = { reason };
|
||||
|
||||
if (
|
||||
opts?.with_relations &&
|
||||
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
|
||||
) {
|
||||
throw new Error(
|
||||
"Server does not support relation based redactions " +
|
||||
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
|
||||
);
|
||||
if (opts?.with_rel_types !== undefined) {
|
||||
if (this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported) {
|
||||
throw new Error(
|
||||
"Server does not support relation based redactions " +
|
||||
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const withRelTypesPropName =
|
||||
this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
|
||||
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
|
||||
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!;
|
||||
|
||||
content[withRelTypesPropName] = opts.with_rel_types;
|
||||
}
|
||||
|
||||
const withRelations = opts?.with_relations
|
||||
? {
|
||||
[this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
|
||||
? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
|
||||
: MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
|
||||
}
|
||||
: {};
|
||||
|
||||
return this.sendCompleteEvent(
|
||||
roomId,
|
||||
threadId,
|
||||
{
|
||||
type: EventType.RoomRedaction,
|
||||
content: {
|
||||
...withRelations,
|
||||
reason,
|
||||
},
|
||||
content,
|
||||
redacts: eventId,
|
||||
},
|
||||
txnId as string,
|
||||
@@ -5005,7 +4999,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
rpEvent?: MatrixEvent,
|
||||
): Promise<{}> {
|
||||
const room = this.getRoom(roomId);
|
||||
if (room && room.hasPendingEvent(rmEventId)) {
|
||||
if (room?.hasPendingEvent(rmEventId)) {
|
||||
throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
|
||||
}
|
||||
|
||||
@@ -5058,9 +5052,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const key = ts + "_" + url;
|
||||
|
||||
// If there's already a request in flight (or we've handled it), return that instead.
|
||||
const cachedPreview = this.urlPreviewCache[key];
|
||||
if (cachedPreview) {
|
||||
return cachedPreview;
|
||||
if (key in this.urlPreviewCache) {
|
||||
return this.urlPreviewCache[key];
|
||||
}
|
||||
|
||||
const resp = this.http.authedRequest<IPreviewUrlResponse>(
|
||||
@@ -5575,11 +5568,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
room.currentState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
|
||||
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
|
||||
const [timelineEvents, threadedEvents, unknownRelations] =
|
||||
room.partitionThreadedEvents(matrixEvents);
|
||||
|
||||
this.processAggregatedTimelineEvents(room, timelineEvents);
|
||||
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
|
||||
this.processThreadEvents(room, threadedEvents, true);
|
||||
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
|
||||
|
||||
room.oldState.paginationToken = res.end ?? null;
|
||||
if (res.chunk.length === 0) {
|
||||
@@ -5688,11 +5683,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
|
||||
}
|
||||
|
||||
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
|
||||
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
|
||||
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
|
||||
// The target event is not in a thread but process the contextual events, so we can show any threads around it.
|
||||
this.processThreadEvents(timelineSet.room, threadedEvents, true);
|
||||
this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
|
||||
unknownRelations.forEach((event) => timelineSet.relations.aggregateChildEvent(event));
|
||||
|
||||
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
|
||||
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
|
||||
@@ -6232,7 +6228,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
|
||||
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
|
||||
const [timelineEvents, , unknownRelations] = room.partitionThreadedEvents(matrixEvents);
|
||||
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
|
||||
this.processAggregatedTimelineEvents(room, timelineEvents);
|
||||
this.processThreadRoots(
|
||||
@@ -6240,6 +6236,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
|
||||
false,
|
||||
);
|
||||
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
|
||||
|
||||
const atEnd = res.end === undefined || res.end === res.start;
|
||||
|
||||
@@ -7146,29 +7143,75 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a set of room IDs in common with another user
|
||||
* Gets a set of room IDs in common with another user.
|
||||
*
|
||||
* Note: This endpoint is unstable, and can throw an `Error`.
|
||||
* Check progress on [MSC2666](https://github.com/matrix-org/matrix-spec-proposals/pull/2666) for more details.
|
||||
*
|
||||
* @param userId - The userId to check.
|
||||
* @returns Promise which resolves to a set of rooms
|
||||
* @returns Promise which resolves to an array of rooms
|
||||
* @returns Rejects: with an error response.
|
||||
*/
|
||||
// TODO: on spec release, rename this to getMutualRooms
|
||||
// eslint-disable-next-line
|
||||
public async _unstable_getSharedRooms(userId: string): Promise<string[]> {
|
||||
const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666");
|
||||
const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms");
|
||||
// Initial variant of the MSC
|
||||
const sharedRoomsSupport = await this.doesServerSupportUnstableFeature(UNSTABLE_MSC2666_SHARED_ROOMS);
|
||||
|
||||
if (!sharedRoomsSupport && !mutualRoomsSupport) {
|
||||
throw Error("Server does not support mutual_rooms API");
|
||||
}
|
||||
// Newer variant that renamed shared rooms to mutual rooms
|
||||
const mutualRoomsSupport = await this.doesServerSupportUnstableFeature(UNSTABLE_MSC2666_MUTUAL_ROOMS);
|
||||
|
||||
const path = utils.encodeUri(
|
||||
`/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`,
|
||||
{ $userId: userId },
|
||||
// Latest variant that changed from path elements to query elements
|
||||
const queryMutualRoomsSupport = await this.doesServerSupportUnstableFeature(
|
||||
UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS,
|
||||
);
|
||||
|
||||
const res = await this.http.authedRequest<{ joined: string[] }>(Method.Get, path, undefined, undefined, {
|
||||
prefix: ClientPrefix.Unstable,
|
||||
});
|
||||
return res.joined;
|
||||
if (!sharedRoomsSupport && !mutualRoomsSupport && !queryMutualRoomsSupport) {
|
||||
throw Error("Server does not support the Mutual Rooms API");
|
||||
}
|
||||
|
||||
let path;
|
||||
let query;
|
||||
|
||||
// Cascading unstable support switching.
|
||||
if (queryMutualRoomsSupport) {
|
||||
path = "/uk.half-shot.msc2666/user/mutual_rooms";
|
||||
query = { user_id: userId };
|
||||
} else {
|
||||
path = utils.encodeUri(
|
||||
`/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`,
|
||||
{ $userId: userId },
|
||||
);
|
||||
query = {};
|
||||
}
|
||||
|
||||
// Accumulated rooms
|
||||
const rooms: string[] = [];
|
||||
let token = null;
|
||||
|
||||
do {
|
||||
const tokenQuery: Record<string, string> = {};
|
||||
if (token != null && queryMutualRoomsSupport) {
|
||||
tokenQuery["batch_token"] = token;
|
||||
}
|
||||
|
||||
const res = await this.http.authedRequest<{
|
||||
joined: string[];
|
||||
next_batch_token?: string;
|
||||
}>(Method.Get, path, { ...query, ...tokenQuery }, undefined, {
|
||||
prefix: ClientPrefix.Unstable,
|
||||
});
|
||||
|
||||
rooms.push(...res.joined);
|
||||
|
||||
if (res.next_batch_token !== undefined) {
|
||||
token = res.next_batch_token;
|
||||
} else {
|
||||
token = null;
|
||||
}
|
||||
} while (token != null);
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7687,16 +7730,27 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @returns Rejects with an error response.
|
||||
*/
|
||||
public refreshToken(refreshToken: string): Promise<IRefreshTokenResponse> {
|
||||
return this.http.authedRequest(
|
||||
Method.Post,
|
||||
"/refresh",
|
||||
undefined,
|
||||
{ refresh_token: refreshToken },
|
||||
{
|
||||
prefix: ClientPrefix.V1,
|
||||
inhibitLogoutEmit: true, // we don't want to cause logout loops
|
||||
},
|
||||
);
|
||||
const performRefreshRequestWithPrefix = (prefix: ClientPrefix): Promise<IRefreshTokenResponse> =>
|
||||
this.http.authedRequest(
|
||||
Method.Post,
|
||||
"/refresh",
|
||||
undefined,
|
||||
{ refresh_token: refreshToken },
|
||||
{
|
||||
prefix,
|
||||
inhibitLogoutEmit: true, // we don't want to cause logout loops
|
||||
},
|
||||
);
|
||||
|
||||
// First try with the (specced) /v3/ prefix.
|
||||
// However, before Synapse 1.72.0, Synapse incorrectly required a /v1/ prefix, so we fall
|
||||
// back to that if the request fails, for backwards compatibility.
|
||||
return performRefreshRequestWithPrefix(ClientPrefix.V3).catch((e) => {
|
||||
if (e.errcode === "M_UNRECOGNIZED") {
|
||||
return performRefreshRequestWithPrefix(ClientPrefix.V1);
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9572,11 +9626,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
/**
|
||||
* A helper to determine intentional mentions support
|
||||
* @returns a boolean to determine if intentional mentions are enabled
|
||||
* @returns a boolean to determine if intentional mentions are enabled on the server
|
||||
* @experimental
|
||||
*/
|
||||
public supportsIntentionalMentions(): boolean {
|
||||
return this.clientOpts?.intentionalMentions || false;
|
||||
return this.canSupport.get(Feature.IntentionalMentions) !== ServerSupport.Unsupported;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+68
-4
@@ -82,6 +82,15 @@ export interface CryptoApi {
|
||||
*/
|
||||
exportRoomKeys(): Promise<IMegolmSessionData[]>;
|
||||
|
||||
/**
|
||||
* Import a list of room keys previously exported by exportRoomKeys
|
||||
*
|
||||
* @param keys - a list of session export objects
|
||||
* @param opts - options object
|
||||
* @returns a promise which resolves once the keys have been imported
|
||||
*/
|
||||
importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the device information for the given list of users.
|
||||
*
|
||||
@@ -114,7 +123,7 @@ export interface CryptoApi {
|
||||
/**
|
||||
* Return whether we trust other user's signatures of their devices.
|
||||
*
|
||||
* @see {@link CryptoApi#setTrustCrossSignedDevices}
|
||||
* @see {@link Crypto.CryptoApi#setTrustCrossSignedDevices}
|
||||
*
|
||||
* @returns `true` if we trust cross-signed devices, otherwise `false`.
|
||||
*/
|
||||
@@ -126,7 +135,8 @@ export interface CryptoApi {
|
||||
* @param userId - The ID of the user whose device is to be checked.
|
||||
* @param deviceId - The ID of the device to check
|
||||
*
|
||||
* @returns Verification status of the device, or `null` if the device is not known
|
||||
* @returns `null` if the device is unknown, or has not published any encryption keys (implying it does not support
|
||||
* encryption); otherwise the verification status of the device.
|
||||
*/
|
||||
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
|
||||
|
||||
@@ -147,7 +157,7 @@ export interface CryptoApi {
|
||||
/**
|
||||
* Get the ID of one of the user's cross-signing keys.
|
||||
*
|
||||
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigngingKey.SelfSigning`,
|
||||
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigningKey.SelfSigning`,
|
||||
* or `CrossSigningKey.UserSigning`. Defaults to `CrossSigningKey.Master`.
|
||||
*
|
||||
* @returns If cross-signing has been initialised on this device, the ID of the given key. Otherwise, null
|
||||
@@ -184,6 +194,13 @@ export interface CryptoApi {
|
||||
* @returns True if secret storage is ready to be used on this device
|
||||
*/
|
||||
isSecretStorageReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the status of our cross-signing keys.
|
||||
*
|
||||
* @returns The current status of cross-signing keys: whether we have public and private keys cached locally, and whether the private keys are in secret storage.
|
||||
*/
|
||||
getCrossSigningStatus(): Promise<CrossSigningStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +269,7 @@ export class DeviceVerificationStatus {
|
||||
* A device is "verified" if either:
|
||||
* * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
|
||||
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
|
||||
* cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
* cross-signed devices via {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
|
||||
*
|
||||
* @returns true if this device is verified via any means.
|
||||
*/
|
||||
@@ -260,3 +277,50 @@ export class DeviceVerificationStatus {
|
||||
return this.localVerified || (this.trustCrossSignedDevices && this.crossSigningVerified);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Room key import progress report.
|
||||
* Used when calling {@link CryptoApi#importRoomKeys} as the parameter of
|
||||
* the progressCallback. Used to display feedback.
|
||||
*/
|
||||
export interface ImportRoomKeyProgressData {
|
||||
stage: string; // TODO: Enum
|
||||
successes: number;
|
||||
failures: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options object for {@link CryptoApi#importRoomKeys}.
|
||||
*/
|
||||
export interface ImportRoomKeysOpts {
|
||||
/** Reports ongoing progress of the import process. Can be used for feedback. */
|
||||
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
|
||||
// TODO, the rust SDK will always such imported keys as untrusted
|
||||
untrusted?: boolean;
|
||||
source?: String; // TODO: Enum (backup, file, ??)
|
||||
}
|
||||
|
||||
export * from "./crypto-api/verification";
|
||||
|
||||
/**
|
||||
* The result of a call to {@link CryptoApi.getCrossSigningStatus}.
|
||||
*/
|
||||
export interface CrossSigningStatus {
|
||||
/**
|
||||
* True if the public master, self signing and user signing keys are available on this device.
|
||||
*/
|
||||
publicKeysOnDevice: boolean;
|
||||
/**
|
||||
* True if the private keys are stored in the secret storage.
|
||||
*/
|
||||
privateKeysInSecretStorage: boolean;
|
||||
/**
|
||||
* True if the private keys are stored locally.
|
||||
*/
|
||||
privateKeysCachedLocally: {
|
||||
masterKey: boolean;
|
||||
selfSigningKey: boolean;
|
||||
userSigningKey: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
|
||||
/**
|
||||
* A `Verifier` is responsible for performing the verification using a particular method, such as via QR code or SAS
|
||||
* (emojis).
|
||||
*
|
||||
* A verifier object can be created by calling `VerificationRequest.beginVerification`; one is also created
|
||||
* automatically when a `m.key.verification.start` event is received for an existing VerificationRequest.
|
||||
*
|
||||
* Once a verifier object is created, the verification can be started by calling the {@link Verifier#verify} method.
|
||||
*/
|
||||
export interface Verifier extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap> {
|
||||
/**
|
||||
* Returns true if the verification has been cancelled, either by us or the other side.
|
||||
*/
|
||||
get hasBeenCancelled(): boolean;
|
||||
|
||||
/**
|
||||
* The ID of the other user in the verification process.
|
||||
*/
|
||||
get userId(): string;
|
||||
|
||||
/**
|
||||
* Start the key verification, if it has not already been started.
|
||||
*
|
||||
* This means sending a `m.key.verification.start` if we are the first responder, or a `m.key.verification.accept`
|
||||
* if the other side has already sent a start event.
|
||||
*
|
||||
* @returns Promise which resolves when the verification has completed, or rejects if the verification is cancelled
|
||||
* or times out.
|
||||
*/
|
||||
verify(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cancel a verification.
|
||||
*
|
||||
* We will send an `m.key.verification.cancel` if the verification is still in flight. The verification promise
|
||||
* will reject, and a {@link Crypto.VerifierEvent#Cancel} will be emitted.
|
||||
*
|
||||
* @param e - the reason for the cancellation.
|
||||
*/
|
||||
cancel(e: Error): void;
|
||||
|
||||
/**
|
||||
* Get the details for an SAS verification, if one is in progress
|
||||
*
|
||||
* Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm
|
||||
* the SAS matches.
|
||||
*/
|
||||
getShowSasCallbacks(): ShowSasCallbacks | null;
|
||||
|
||||
/**
|
||||
* Get the details for reciprocating QR code verification, if one is in progress
|
||||
*
|
||||
* Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has
|
||||
* already scanned our QR code), and we are waiting for the user to confirm.
|
||||
*/
|
||||
getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null;
|
||||
}
|
||||
|
||||
/** Events emitted by {@link Verifier} */
|
||||
export enum VerifierEvent {
|
||||
/**
|
||||
* The verification has been cancelled, by us or the other side.
|
||||
*
|
||||
* The payload is either an {@link Error}, or an (incoming or outgoing) {@link MatrixEvent}, depending on
|
||||
* unspecified reasons.
|
||||
*/
|
||||
Cancel = "cancel",
|
||||
|
||||
/**
|
||||
* SAS data has been exchanged and should be displayed to the user.
|
||||
*
|
||||
* The payload is the {@link ShowSasCallbacks} object.
|
||||
*/
|
||||
ShowSas = "show_sas",
|
||||
|
||||
/**
|
||||
* QR code data should be displayed to the user.
|
||||
*
|
||||
* The payload is the {@link ShowQrCodeCallbacks} object.
|
||||
*/
|
||||
ShowReciprocateQr = "show_reciprocate_qr",
|
||||
}
|
||||
|
||||
/** Listener type map for {@link VerifierEvent}s. */
|
||||
export type VerifierEventHandlerMap = {
|
||||
[VerifierEvent.Cancel]: (e: Error | MatrixEvent) => void;
|
||||
[VerifierEvent.ShowSas]: (sas: ShowSasCallbacks) => void;
|
||||
[VerifierEvent.ShowReciprocateQr]: (qr: ShowQrCodeCallbacks) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callbacks for user actions while a QR code is displayed.
|
||||
*
|
||||
* This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
|
||||
* verifier as `reciprocateQREvent`.
|
||||
*/
|
||||
export interface ShowQrCodeCallbacks {
|
||||
/** The user confirms that the verification data matches */
|
||||
confirm(): void;
|
||||
|
||||
/** Cancel the verification flow */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks for user actions while a SAS is displayed.
|
||||
*
|
||||
* This is exposed as the payload of a `VerifierEvent.ShowSas` event, or directly from the verifier as `sasEvent`.
|
||||
*/
|
||||
export interface ShowSasCallbacks {
|
||||
/** The generated SAS to be shown to the user */
|
||||
sas: GeneratedSas;
|
||||
|
||||
/** Function to call if the user confirms that the SAS matches.
|
||||
*
|
||||
* @returns A Promise that completes once the m.key.verification.mac is queued.
|
||||
*/
|
||||
confirm(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to call if the user finds the SAS does not match.
|
||||
*
|
||||
* Sends an `m.key.verification.cancel` event with a `m.mismatched_sas` error code.
|
||||
*/
|
||||
mismatch(): void;
|
||||
|
||||
/** Cancel the verification flow */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** A generated SAS to be shown to the user, in alternative formats */
|
||||
export interface GeneratedSas {
|
||||
/**
|
||||
* The SAS as three numbers between 0 and 8191.
|
||||
*
|
||||
* Only populated if the `decimal` SAS method was negotiated.
|
||||
*/
|
||||
decimal?: [number, number, number];
|
||||
|
||||
/**
|
||||
* The SAS as seven emojis.
|
||||
*
|
||||
* Only populated if the `emoji` SAS method was negotiated.
|
||||
*/
|
||||
emoji?: EmojiMapping[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An emoji for the generated SAS. A tuple `[emoji, name]` where `emoji` is the emoji itself and `name` is the
|
||||
* English name.
|
||||
*/
|
||||
export type EmojiMapping = [emoji: string, name: string];
|
||||
@@ -688,7 +688,7 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O
|
||||
_expectedPublicKey: string,
|
||||
): Promise<Uint8Array> {
|
||||
const key = await new Promise<any>((resolve) => {
|
||||
return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
|
||||
store.getSecretStorePrivateKey(txn, resolve, type);
|
||||
});
|
||||
});
|
||||
@@ -790,7 +790,7 @@ export async function requestKeysDuringVerification(
|
||||
})();
|
||||
|
||||
// We call getCrossSigningKey() for its side-effects
|
||||
return Promise.race<KeysDuringVerification | void>([
|
||||
Promise.race<KeysDuringVerification | void>([
|
||||
Promise.all([
|
||||
crossSigning.getCrossSigningKey("master"),
|
||||
crossSigning.getCrossSigningKey("self_signing"),
|
||||
|
||||
@@ -116,7 +116,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
|
||||
public async load(): Promise<void> {
|
||||
await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
|
||||
this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
|
||||
this.hasFetched = Boolean(deviceData && deviceData.devices);
|
||||
this.hasFetched = Boolean(deviceData?.devices);
|
||||
this.devices = deviceData ? deviceData.devices : {};
|
||||
this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {};
|
||||
this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
|
||||
|
||||
+6
-14
@@ -20,6 +20,12 @@ import type { AddSecretStorageKeyOpts } from "../secret-storage";
|
||||
|
||||
/* re-exports for backwards compatibility. */
|
||||
export { CrossSigningKey } from "../crypto-api";
|
||||
|
||||
export type {
|
||||
ImportRoomKeyProgressData as IImportOpts,
|
||||
ImportRoomKeysOpts as IImportRoomKeysOpts,
|
||||
} from "../crypto-api";
|
||||
|
||||
export type {
|
||||
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
|
||||
PassphraseInfo as IPassphraseInfo,
|
||||
@@ -100,17 +106,3 @@ export interface ICreateSecretStorageOpts {
|
||||
*/
|
||||
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface IImportOpts {
|
||||
stage: string; // TODO: Enum
|
||||
successes: number;
|
||||
failures: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IImportRoomKeysOpts {
|
||||
/** called with an object that has a "stage" param */
|
||||
progressCallback?: (stage: IImportOpts) => void;
|
||||
untrusted?: boolean;
|
||||
source?: string; // TODO: Enum
|
||||
}
|
||||
|
||||
+38
-15
@@ -35,13 +35,7 @@ import * as algorithms from "./algorithms";
|
||||
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
|
||||
import { EncryptionSetupBuilder } from "./EncryptionSetup";
|
||||
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
ICreateSecretStorageOpts,
|
||||
IEncryptedEventInfo,
|
||||
IImportRoomKeysOpts,
|
||||
IRecoveryKey,
|
||||
} from "./api";
|
||||
import { CrossSigningKey, ICreateSecretStorageOpts, IEncryptedEventInfo, IRecoveryKey } from "./api";
|
||||
import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import { VerificationBase } from "./verification/Base";
|
||||
@@ -93,7 +87,12 @@ import {
|
||||
ServerSideSecretStorageImpl,
|
||||
} from "../secret-storage";
|
||||
import { ISecretRequest } from "./SecretSharing";
|
||||
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
|
||||
import {
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DeviceVerificationStatus,
|
||||
ImportRoomKeysOpts,
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
|
||||
@@ -611,7 +610,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link CryptoApi#getTrustCrossSignedDevices}.
|
||||
* @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}.
|
||||
*/
|
||||
public getCryptoTrustCrossSignedDevices(): boolean {
|
||||
return this.trustCrossSignedDevices;
|
||||
@@ -641,7 +640,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link CryptoApi#setTrustCrossSignedDevices}.
|
||||
* @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
|
||||
*/
|
||||
public setCryptoTrustCrossSignedDevices(val: boolean): void {
|
||||
this.setTrustCrossSignedDevices(val);
|
||||
@@ -744,6 +743,30 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getCrossSigningStatus}
|
||||
*/
|
||||
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
|
||||
const publicKeysOnDevice = Boolean(this.crossSigningInfo.getId());
|
||||
const privateKeysInSecretStorage = Boolean(
|
||||
await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage),
|
||||
);
|
||||
const cacheCallbacks = this.crossSigningInfo.getCacheCallbacks();
|
||||
const masterKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("master"));
|
||||
const selfSigningKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("self_signing"));
|
||||
const userSigningKey = Boolean(await cacheCallbacks.getCrossSigningKeyCache?.("user_signing"));
|
||||
|
||||
return {
|
||||
publicKeysOnDevice,
|
||||
privateKeysInSecretStorage,
|
||||
privateKeysCachedLocally: {
|
||||
masterKey,
|
||||
selfSigningKey,
|
||||
userSigningKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap cross-signing by creating keys if needed. If everything is already
|
||||
* set up, then no changes are made, so this is safe to run to ensure
|
||||
@@ -1473,7 +1496,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
|
||||
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
|
||||
*/
|
||||
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
|
||||
const device = this.deviceList.getStoredDevice(userId, deviceId);
|
||||
@@ -1486,7 +1509,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @param userId - The ID of the user whose devices is to be checked.
|
||||
* @param device - The device info object to check
|
||||
*
|
||||
* @deprecated Use {@link CryptoApi.getDeviceVerificationStatus}.
|
||||
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
|
||||
*/
|
||||
public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
|
||||
const trustedLocally = !!device?.isVerified();
|
||||
@@ -1835,7 +1858,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @param value - whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated Set {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
* @deprecated Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
|
||||
this.globalBlacklistUnverifiedDevices = value;
|
||||
@@ -1844,7 +1867,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
/**
|
||||
* @returns whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated Reference {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
* @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public getGlobalBlacklistUnverifiedDevices(): boolean {
|
||||
return this.globalBlacklistUnverifiedDevices;
|
||||
@@ -2828,7 +2851,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* @param keys - a list of session export objects
|
||||
* @returns a promise which resolves once the keys have been imported
|
||||
*/
|
||||
public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts = {}): Promise<void> {
|
||||
public importRoomKeys(keys: IMegolmSessionData[], opts: ImportRoomKeysOpts = {}): Promise<void> {
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
const total = keys.length;
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from "../../logger";
|
||||
import { deepCompare, promiseTry } from "../../utils";
|
||||
import { safeSet, deepCompare, promiseTry } from "../../utils";
|
||||
import {
|
||||
CryptoStore,
|
||||
IDeviceData,
|
||||
@@ -33,7 +33,6 @@ import { ICrossSigningKey } from "../../client";
|
||||
import { IOlmDevice } from "../algorithms/megolm";
|
||||
import { IRoomEncryption } from "../RoomList";
|
||||
import { InboundGroupSessionData } from "../OlmDevice";
|
||||
import { safeSet } from "../../utils";
|
||||
|
||||
/**
|
||||
* Internal module. in-memory storage for e2e.
|
||||
|
||||
@@ -28,7 +28,14 @@ import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossS
|
||||
import { IVerificationChannel } from "./request/Channel";
|
||||
import { MatrixClient } from "../../client";
|
||||
import { VerificationRequest } from "./request/VerificationRequest";
|
||||
import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter";
|
||||
import { TypedEventEmitter } from "../../models/typed-event-emitter";
|
||||
import {
|
||||
ShowQrCodeCallbacks,
|
||||
ShowSasCallbacks,
|
||||
Verifier,
|
||||
VerifierEvent,
|
||||
VerifierEventHandlerMap,
|
||||
} from "../../crypto-api/verification";
|
||||
|
||||
const timeoutException = new Error("Verification timed out");
|
||||
|
||||
@@ -40,18 +47,28 @@ export class SwitchStartEventError extends Error {
|
||||
|
||||
export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void;
|
||||
|
||||
export enum VerificationEvent {
|
||||
Cancel = "cancel",
|
||||
}
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type VerificationEvent = VerifierEvent;
|
||||
/** @deprecated use VerifierEvent */
|
||||
export const VerificationEvent = VerifierEvent;
|
||||
|
||||
/** @deprecated use VerifierEventHandlerMap */
|
||||
export type VerificationEventHandlerMap = {
|
||||
[VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void;
|
||||
};
|
||||
|
||||
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
|
||||
// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain
|
||||
// backwards compatibility with applications that reference the class.
|
||||
export class VerificationBase<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events | VerificationEvent>,
|
||||
> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Events extends string = VerifierEvent,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Arguments = VerifierEventHandlerMap,
|
||||
>
|
||||
extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap>
|
||||
implements Verifier
|
||||
{
|
||||
private cancelled = false;
|
||||
private _done = false;
|
||||
private promise: Promise<void> | null = null;
|
||||
@@ -366,4 +383,24 @@ export class VerificationBase<
|
||||
public get events(): string[] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details for an SAS verification, if one is in progress
|
||||
*
|
||||
* Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm
|
||||
* the SAS matches.
|
||||
*/
|
||||
public getShowSasCallbacks(): ShowSasCallbacks | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details for reciprocating QR code verification, if one is in progress
|
||||
*
|
||||
* Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has
|
||||
* already scanned our QR code), and we are waiting for the user to confirm.
|
||||
*/
|
||||
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
* QR code key verification.
|
||||
*/
|
||||
|
||||
import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base";
|
||||
import { VerificationBase as Base } from "./Base";
|
||||
import { newKeyMismatchError, newUserCancelledError } from "./Error";
|
||||
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
|
||||
import { logger } from "../../logger";
|
||||
@@ -26,25 +26,19 @@ import { VerificationRequest } from "./request/VerificationRequest";
|
||||
import { MatrixClient } from "../../client";
|
||||
import { IVerificationChannel } from "./request/Channel";
|
||||
import { MatrixEvent } from "../../models/event";
|
||||
import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification";
|
||||
|
||||
export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
|
||||
export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
|
||||
|
||||
interface IReciprocateQr {
|
||||
confirm(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type QrCodeEvent = VerifierEvent;
|
||||
/** @deprecated use VerifierEvent */
|
||||
export const QrCodeEvent = VerifierEvent;
|
||||
|
||||
export enum QrCodeEvent {
|
||||
ShowReciprocateQr = "show_reciprocate_qr",
|
||||
}
|
||||
|
||||
type EventHandlerMap = {
|
||||
[QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void;
|
||||
} & VerificationEventHandlerMap;
|
||||
|
||||
export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
|
||||
public reciprocateQREvent?: IReciprocateQr;
|
||||
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
|
||||
export class ReciprocateQRCode extends Base {
|
||||
public reciprocateQREvent?: ShowQrCodeCallbacks;
|
||||
|
||||
public static factory(
|
||||
channel: IVerificationChannel,
|
||||
@@ -126,6 +120,10 @@ export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null {
|
||||
return this.reciprocateQREvent ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const CODE_VERSION = 0x02; // the version of binary QR codes we support
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import anotherjson from "another-json";
|
||||
import { Utility, SAS as OlmSAS } from "@matrix-org/olm";
|
||||
|
||||
import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base";
|
||||
import { VerificationBase as Base, SwitchStartEventError } from "./Base";
|
||||
import {
|
||||
errorFactory,
|
||||
newInvalidMessageError,
|
||||
@@ -33,6 +33,14 @@ import { logger } from "../../logger";
|
||||
import { IContent, MatrixEvent } from "../../models/event";
|
||||
import { generateDecimalSas } from "./SASDecimal";
|
||||
import { EventType } from "../../@types/event";
|
||||
import { EmojiMapping, GeneratedSas, ShowSasCallbacks, VerifierEvent } from "../../crypto-api/verification";
|
||||
|
||||
// backwards-compatibility exports
|
||||
export type {
|
||||
ShowSasCallbacks as ISasEvent,
|
||||
GeneratedSas as IGeneratedSas,
|
||||
EmojiMapping,
|
||||
} from "../../crypto-api/verification";
|
||||
|
||||
const START_TYPE = EventType.KeyVerificationStart;
|
||||
|
||||
@@ -44,8 +52,6 @@ const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short
|
||||
|
||||
const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
|
||||
|
||||
type EmojiMapping = [emoji: string, name: string];
|
||||
|
||||
const emojiMapping: EmojiMapping[] = [
|
||||
["🐶", "dog"], // 0
|
||||
["🐱", "cat"], // 1
|
||||
@@ -133,20 +139,8 @@ const sasGenerators = {
|
||||
emoji: generateEmojiSas,
|
||||
} as const;
|
||||
|
||||
export interface IGeneratedSas {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: EmojiMapping[];
|
||||
}
|
||||
|
||||
export interface ISasEvent {
|
||||
sas: IGeneratedSas;
|
||||
confirm(): Promise<void>;
|
||||
cancel(): void;
|
||||
mismatch(): void;
|
||||
}
|
||||
|
||||
function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
|
||||
const sas: IGeneratedSas = {};
|
||||
function generateSas(sasBytes: Uint8Array, methods: string[]): GeneratedSas {
|
||||
const sas: GeneratedSas = {};
|
||||
for (const method of methods) {
|
||||
if (method in sasGenerators) {
|
||||
// @ts-ignore - ts doesn't like us mixing types like this
|
||||
@@ -220,19 +214,17 @@ function intersection<T>(anArray: T[], aSet: Set<T>): T[] {
|
||||
return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : [];
|
||||
}
|
||||
|
||||
export enum SasEvent {
|
||||
ShowSas = "show_sas",
|
||||
}
|
||||
/** @deprecated use VerifierEvent */
|
||||
export type SasEvent = VerifierEvent;
|
||||
/** @deprecated use VerifierEvent */
|
||||
export const SasEvent = VerifierEvent;
|
||||
|
||||
type EventHandlerMap = {
|
||||
[SasEvent.ShowSas]: (sas: ISasEvent) => void;
|
||||
} & VerificationEventHandlerMap;
|
||||
|
||||
export class SAS extends Base<SasEvent, EventHandlerMap> {
|
||||
/** @deprecated Avoid referencing this class directly; instead use {@link Crypto.Verifier}. */
|
||||
export class SAS extends Base {
|
||||
private waitingForAccept?: boolean;
|
||||
public ourSASPubKey?: string;
|
||||
public theirSASPubKey?: string;
|
||||
public sasEvent?: ISasEvent;
|
||||
public sasEvent?: ShowSasCallbacks;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
public static get NAME(): string {
|
||||
@@ -489,4 +481,8 @@ export class SAS extends Base<SasEvent, EventHandlerMap> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getShowSasCallbacks(): ShowSasCallbacks | null {
|
||||
return this.sasEvent ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
// part of a verification request, so be noisy when rejecting something
|
||||
if (type === REQUEST_TYPE) {
|
||||
if (!content || typeof content.to !== "string" || !content.to.length) {
|
||||
logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to));
|
||||
logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
logger.log(
|
||||
"InRoomChannel: validateEvent: " +
|
||||
`not directed to or sent by me: ${event.getSender()}` +
|
||||
`, ${content && content.to}`,
|
||||
`, ${content.to}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -208,10 +208,17 @@ export class InRoomChannel implements IVerificationChannel {
|
||||
this.requestEventId = InRoomChannel.getTransactionId(event);
|
||||
}
|
||||
|
||||
// With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected
|
||||
// back via /sync. These are "local echoes" and are identifiable by their txnId
|
||||
const isLocalEcho = !!event.getTxnId();
|
||||
|
||||
// Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes"
|
||||
// and have a transaction ID in the "unsigned" data
|
||||
const isRemoteEcho = !!event.getUnsigned().transaction_id;
|
||||
|
||||
const isSentByUs = event.getSender() === this.client.getUserId();
|
||||
|
||||
return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
|
||||
return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -163,6 +163,22 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique ID for this verification request.
|
||||
*
|
||||
* An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases.
|
||||
*/
|
||||
public get transactionId(): string | undefined {
|
||||
return this.channel.transactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an in-room verification, the ID of the room.
|
||||
*/
|
||||
public get roomId(): string | undefined {
|
||||
return this.channel.roomId;
|
||||
}
|
||||
|
||||
public get invalid(): boolean {
|
||||
return this.phase === PHASE_UNSENT;
|
||||
}
|
||||
@@ -257,11 +273,23 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED;
|
||||
}
|
||||
|
||||
/** Only set after a .ready if the other party can scan a QR code */
|
||||
/** Only set after a .ready if the other party can scan a QR code
|
||||
*
|
||||
* @deprecated Prefer `getQRCodeBytes`.
|
||||
*/
|
||||
public get qrCodeData(): QRCodeData | null {
|
||||
return this._qrCodeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
|
||||
*
|
||||
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
|
||||
*/
|
||||
public getQRCodeBytes(): Buffer | undefined {
|
||||
return this._qrCodeData?.getBuffer();
|
||||
}
|
||||
|
||||
/** Checks whether the other party supports a given verification method.
|
||||
* This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
|
||||
* if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
|
||||
@@ -347,6 +375,11 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
|
||||
return this.channel.userId!;
|
||||
}
|
||||
|
||||
/** The device id of the other party in this request, for requests happening over to-device messages only. */
|
||||
public get otherDeviceId(): string | undefined {
|
||||
return this.channel.deviceId;
|
||||
}
|
||||
|
||||
public get isSelfVerification(): boolean {
|
||||
return this.client.getUserId() === this.otherUserId;
|
||||
}
|
||||
|
||||
+1
-2
@@ -25,14 +25,13 @@ import {
|
||||
ISendEventFromWidgetResponseData,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { IEvent, IContent, EventStatus } from "./models/event";
|
||||
import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event";
|
||||
import { ISendEventResponse } from "./@types/requests";
|
||||
import { EventType } from "./@types/event";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
|
||||
import { SyncApi, SyncState } from "./sync";
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { User } from "./models/user";
|
||||
import { Room } from "./models/room";
|
||||
import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
|
||||
|
||||
@@ -32,6 +32,7 @@ export enum Feature {
|
||||
RelationBasedRedactions = "RelationBasedRedactions",
|
||||
AccountDataDeletion = "AccountDataDeletion",
|
||||
RelationsRecursion = "RelationsRecursion",
|
||||
IntentionalMentions = "IntentionalMentions",
|
||||
}
|
||||
|
||||
type FeatureSupportCondition = {
|
||||
@@ -60,6 +61,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
|
||||
[Feature.RelationsRecursion]: {
|
||||
unstablePrefixes: ["org.matrix.msc3981"],
|
||||
},
|
||||
[Feature.IntentionalMentions]: {
|
||||
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
|
||||
|
||||
@@ -298,11 +298,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
* @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom".
|
||||
* @param queryParams - A dict of query params (these will NOT be urlencoded).
|
||||
* @param prefix - The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix.
|
||||
* @param baseUrl - The baseUrl to use e.g. "https://matrix.org/", defaulting to this.opts.baseUrl.
|
||||
* @param baseUrl - The baseUrl to use e.g. "https://matrix.org", defaulting to this.opts.baseUrl.
|
||||
* @returns URL
|
||||
*/
|
||||
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
|
||||
const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
|
||||
const baseUrlWithFallback = baseUrl ?? this.opts.baseUrl;
|
||||
const baseUrlWithoutTrailingSlash = baseUrlWithFallback.endsWith("/")
|
||||
? baseUrlWithFallback.slice(0, -1)
|
||||
: baseUrlWithFallback;
|
||||
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
|
||||
if (queryParams) {
|
||||
encodeParams(queryParams, url.searchParams);
|
||||
}
|
||||
|
||||
+29
-4
@@ -26,7 +26,7 @@ const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
||||
const MSISDN_STAGE_TYPE = "m.login.msisdn";
|
||||
|
||||
export interface UIAFlow {
|
||||
stages: AuthType[];
|
||||
stages: Array<AuthType | string>;
|
||||
}
|
||||
|
||||
export interface IInputs {
|
||||
@@ -156,6 +156,14 @@ interface IOpts {
|
||||
*/
|
||||
emailSid?: string;
|
||||
|
||||
/**
|
||||
* If specified, will prefer flows which entirely consist of listed stages.
|
||||
* These should normally be of type AuthTypes but can be string when supporting custom auth stages.
|
||||
*
|
||||
* This can be used to avoid needing the fallback mechanism.
|
||||
*/
|
||||
supportedStages?: Array<AuthType | string>;
|
||||
|
||||
/**
|
||||
* Called with the new auth dict to submit the request.
|
||||
* Also passes a second deprecated arg which is a flag set to true if this request is a background request.
|
||||
@@ -176,7 +184,7 @@ interface IOpts {
|
||||
* m.login.email.identity:
|
||||
* * emailSid: string, the sid of the active email auth session
|
||||
*/
|
||||
stateUpdated(nextStage: AuthType, status: IStageStatus): void;
|
||||
stateUpdated(nextStage: AuthType | string, status: IStageStatus): void;
|
||||
|
||||
/**
|
||||
* A function that takes the email address (string), clientSecret (string), attempt number (int) and
|
||||
@@ -216,6 +224,7 @@ export class InteractiveAuth {
|
||||
private readonly busyChangedCallback?: IOpts["busyChanged"];
|
||||
private readonly stateUpdatedCallback: IOpts["stateUpdated"];
|
||||
private readonly requestEmailTokenCallback: IOpts["requestEmailToken"];
|
||||
private readonly supportedStages?: Set<string>;
|
||||
|
||||
private data: IAuthData;
|
||||
private emailSid?: string;
|
||||
@@ -243,6 +252,7 @@ export class InteractiveAuth {
|
||||
if (opts.sessionId) this.data.session = opts.sessionId;
|
||||
this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret();
|
||||
this.emailSid = opts.emailSid;
|
||||
if (opts.supportedStages !== undefined) this.supportedStages = new Set(opts.supportedStages);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,7 +581,7 @@ export class InteractiveAuth {
|
||||
* @returns login type
|
||||
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
|
||||
*/
|
||||
private chooseStage(): AuthType | undefined {
|
||||
private chooseStage(): AuthType | string | undefined {
|
||||
if (this.chosenFlow === null) {
|
||||
this.chosenFlow = this.chooseFlow();
|
||||
}
|
||||
@@ -581,6 +591,17 @@ export class InteractiveAuth {
|
||||
return nextStage;
|
||||
}
|
||||
|
||||
// Returns a low number for flows we consider best. Counts increase for longer flows and even more so
|
||||
// for flows which contain stages not listed in `supportedStages`.
|
||||
private scoreFlow(flow: UIAFlow): number {
|
||||
let score = flow.stages.length;
|
||||
if (this.supportedStages !== undefined) {
|
||||
// Add 10 points to the score for each unsupported stage in the flow.
|
||||
score += flow.stages.filter((stage) => !this.supportedStages!.has(stage)).length * 10;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one of the flows from the returned list
|
||||
* If a flow using all of the inputs is found, it will
|
||||
@@ -603,6 +624,10 @@ export class InteractiveAuth {
|
||||
const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid);
|
||||
const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber);
|
||||
|
||||
// Flows are not represented in a significant order, so we can choose any we support best
|
||||
// Sort flows based on how many unsupported stages they contain ascending
|
||||
flows.sort((a, b) => this.scoreFlow(a) - this.scoreFlow(b));
|
||||
|
||||
for (const flow of flows) {
|
||||
let flowHasEmail = false;
|
||||
let flowHasMsisdn = false;
|
||||
@@ -633,7 +658,7 @@ export class InteractiveAuth {
|
||||
* @internal
|
||||
* @returns login type
|
||||
*/
|
||||
private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
|
||||
private firstUncompletedStage(flow: UIAFlow): AuthType | string | undefined {
|
||||
const completed = this.data.completed || [];
|
||||
return flow.stages.find((stageType) => !completed.includes(stageType));
|
||||
}
|
||||
|
||||
+24
-2
@@ -37,6 +37,8 @@ export * from "./models/event-timeline-set";
|
||||
export * from "./models/poll";
|
||||
export * from "./models/room-member";
|
||||
export * from "./models/room-state";
|
||||
export * from "./models/thread";
|
||||
export * from "./models/typed-event-emitter";
|
||||
export * from "./models/user";
|
||||
export * from "./models/device";
|
||||
export * from "./scheduler";
|
||||
@@ -63,10 +65,30 @@ export { createNewMatrixCall } from "./webrtc/call";
|
||||
export type { MatrixCall } from "./webrtc/call";
|
||||
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
|
||||
export type { GroupCall } from "./webrtc/groupCall";
|
||||
export type { CryptoApi } from "./crypto-api";
|
||||
export { DeviceVerificationStatus } from "./crypto-api";
|
||||
export { CryptoEvent } from "./crypto";
|
||||
|
||||
/**
|
||||
* Types supporting cryptography.
|
||||
*
|
||||
* The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via
|
||||
* {@link MatrixClient.getCrypto}.
|
||||
*/
|
||||
export * as Crypto from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
* @internal
|
||||
* @deprecated use {@link Crypto.CryptoApi}
|
||||
*/
|
||||
export type { CryptoApi } from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
* @internal
|
||||
* @deprecated use {@link Crypto.DeviceVerificationStatus}
|
||||
*/
|
||||
export { DeviceVerificationStatus } from "./crypto-api";
|
||||
|
||||
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ export type DeviceMap = Map<string, Map<string, Device>>;
|
||||
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
|
||||
|
||||
/**
|
||||
* Information on a user's device, as returned by {@link CryptoApi.getUserDeviceInfo}.
|
||||
* Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}.
|
||||
*/
|
||||
export class Device {
|
||||
/** id of the device */
|
||||
|
||||
@@ -756,6 +756,94 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert event to the given timeline, and emit Room.timeline. Assumes
|
||||
* we have already checked we don't know about this event.
|
||||
*
|
||||
* TEMPORARY: until we have recursive relations, we need this function
|
||||
* to exist to allow us to insert events in timeline order, which is our
|
||||
* best guess for Sync Order.
|
||||
* This is a copy of addEventToTimeline above, modified to insert the event
|
||||
* after the event it relates to, and before any event with a later
|
||||
* timestamp. This is our best guess at Sync Order.
|
||||
*
|
||||
* Will fire "Room.timeline" for each event added.
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param options - addEventToTimeline options
|
||||
*
|
||||
* @remarks
|
||||
* Fires {@link RoomEvent.Timeline}
|
||||
*/
|
||||
public insertEventIntoTimeline(event: MatrixEvent, timeline: EventTimeline, roomState: RoomState): void {
|
||||
if (timeline.getTimelineSet() !== this) {
|
||||
throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
|
||||
"in timelineSet(threadId=${this.thread?.id})`);
|
||||
}
|
||||
|
||||
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
|
||||
// threaded message should not be in the main timeline).
|
||||
//
|
||||
// We can only run this check for timelines with a `room` because `canContain`
|
||||
// requires it
|
||||
if (this.room && !this.canContain(event)) {
|
||||
let eventDebugString = `event=${event.getId()}`;
|
||||
if (event.threadRootId) {
|
||||
eventDebugString += `(belongs to thread=${event.threadRootId})`;
|
||||
}
|
||||
logger.warn(
|
||||
`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
|
||||
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the event that this event is related to - the "parent"
|
||||
const parentEventId = event.relationEventId;
|
||||
if (!parentEventId) {
|
||||
// Not related to anything - we just append
|
||||
this.addEventToTimeline(event, timeline, {
|
||||
toStartOfTimeline: false,
|
||||
fromCache: false,
|
||||
timelineWasEmpty: false,
|
||||
roomState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parentEvent = this.findEventById(parentEventId);
|
||||
|
||||
const timelineEvents = timeline.getEvents();
|
||||
|
||||
// Start searching from the parent event, or if it's not loaded, start
|
||||
// at the beginning and insert purely using timestamp order.
|
||||
const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0;
|
||||
let insertIndex = parentIndex;
|
||||
for (; insertIndex < timelineEvents.length; insertIndex++) {
|
||||
const nextEvent = timelineEvents[insertIndex];
|
||||
if (nextEvent.getTs() > event.getTs()) {
|
||||
// We found an event later than ours, so insert before that.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If we got to the end of the loop, insertIndex points at the end of
|
||||
// the list.
|
||||
|
||||
const eventId = event.getId()!;
|
||||
timeline.insertEvent(event, insertIndex, roomState);
|
||||
this._eventIdToTimeline.set(eventId, timeline);
|
||||
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
const data: IRoomTimelineData = {
|
||||
timeline: timeline,
|
||||
liveEvent: timeline == this.liveTimeline,
|
||||
};
|
||||
this.emit(RoomEvent.Timeline, event, this.room, false, false, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces event with ID oldEventId with one with newEventId, if oldEventId is
|
||||
* recognised. Otherwise, add to the live timeline. Used to handle remote echos.
|
||||
|
||||
@@ -427,6 +427,45 @@ export class EventTimeline {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new event into the timeline, and update the state.
|
||||
*
|
||||
* TEMPORARY: until we have recursive relations, we need this function
|
||||
* to exist to allow us to insert events in timeline order, which is our
|
||||
* best guess for Sync Order.
|
||||
* This is a copy of addEvent above, modified to allow inserting an event at
|
||||
* a specific index.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public insertEvent(event: MatrixEvent, insertIndex: number, roomState: RoomState): void {
|
||||
const timelineSet = this.getTimelineSet();
|
||||
|
||||
if (timelineSet.room) {
|
||||
EventTimeline.setEventMetadata(event, roomState, false);
|
||||
|
||||
// modify state but only on unfiltered timelineSets
|
||||
if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
|
||||
roomState.setStateEvents([event], {});
|
||||
// it is possible that the act of setting the state event means we
|
||||
// can set more metadata (specifically sender/target props), so try
|
||||
// it again if the prop wasn't previously set. It may also mean that
|
||||
// the sender/target is updated (if the event set was a room member event)
|
||||
// so we want to use the *updated* member (new avatar/name) instead.
|
||||
//
|
||||
// However, we do NOT want to do this on member events if we're going
|
||||
// back in time, else we'll set the .sender value for BEFORE the given
|
||||
// member event, whereas we want to set the .sender value for the ACTUAL
|
||||
// member event itself.
|
||||
if (!event.sender || event.getType() === EventType.RoomMember) {
|
||||
EventTimeline.setEventMetadata(event, roomState, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.events.splice(insertIndex, 0, event); // insert element
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event from the timeline
|
||||
*
|
||||
|
||||
+21
-5
@@ -24,11 +24,17 @@ import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk";
|
||||
import type { IEventDecryptionResult } from "../@types/crypto";
|
||||
import { logger } from "../logger";
|
||||
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
|
||||
import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
|
||||
import {
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
} from "../@types/event";
|
||||
import { Crypto } from "../crypto";
|
||||
import { deepSortedObjectEntries, internaliseString } from "../utils";
|
||||
import { RoomMember } from "./room-member";
|
||||
import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
|
||||
import { Thread, ThreadEvent, ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
|
||||
import { IActionsObject } from "../pushprocessor";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
import { MatrixError } from "../http-api";
|
||||
@@ -63,6 +69,7 @@ export interface IUnsigned {
|
||||
"transaction_id"?: string;
|
||||
"invite_room_state"?: StrippedState[];
|
||||
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
|
||||
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
|
||||
}
|
||||
|
||||
export interface IThreadBundledRelationship {
|
||||
@@ -572,9 +579,18 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
const relatesTo = this.getWireContent()?.["m.relates_to"];
|
||||
if (relatesTo?.rel_type === THREAD_RELATION_TYPE.name) {
|
||||
return relatesTo.event_id;
|
||||
} else {
|
||||
return this.getThread()?.id || this.threadId;
|
||||
}
|
||||
if (this.thread) {
|
||||
return this.thread.id;
|
||||
}
|
||||
if (this.threadId !== undefined) {
|
||||
return this.threadId;
|
||||
}
|
||||
const unsigned = this.getUnsigned();
|
||||
if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") {
|
||||
return unsigned[UNSIGNED_THREAD_ID_FIELD.name];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -586,7 +602,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// Bundled relationships only returned when the sync response is limited
|
||||
// hence us having to check both bundled relation and inspect the thread
|
||||
// model
|
||||
return !!threadDetails || this.getThread()?.id === this.getId();
|
||||
return !!threadDetails || this.threadRootId === this.getId();
|
||||
}
|
||||
|
||||
public get replyEventId(): string | undefined {
|
||||
|
||||
@@ -186,7 +186,7 @@ export class IgnoredInvites {
|
||||
}
|
||||
let regexp: RegExp;
|
||||
try {
|
||||
regexp = new RegExp(globToRegexp(glob, false));
|
||||
regexp = new RegExp(globToRegexp(glob));
|
||||
} catch (ex) {
|
||||
// Assume invalid event.
|
||||
continue;
|
||||
|
||||
+77
-29
@@ -39,6 +39,7 @@ import {
|
||||
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
RelationType,
|
||||
UNSIGNED_THREAD_ID_FIELD,
|
||||
} from "../@types/event";
|
||||
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
|
||||
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
|
||||
@@ -48,7 +49,7 @@ import { BeaconEvent, BeaconEventHandlerMap } from "./beacon";
|
||||
import {
|
||||
Thread,
|
||||
ThreadEvent,
|
||||
EventHandlerMap as ThreadHandlerMap,
|
||||
ThreadEventHandlerMap as ThreadHandlerMap,
|
||||
FILTER_RELATED_BY_REL_TYPES,
|
||||
THREAD_RELATION_TYPE,
|
||||
FILTER_RELATED_BY_SENDERS,
|
||||
@@ -72,8 +73,8 @@ import { isPollEvent, Poll, PollEvent } from "./poll";
|
||||
// room versions which are considered okay for people to run without being asked
|
||||
// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
|
||||
// return an m.room_versions capability.
|
||||
export const KNOWN_SAFE_ROOM_VERSION = "9";
|
||||
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||
export const KNOWN_SAFE_ROOM_VERSION = "10";
|
||||
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
|
||||
|
||||
interface IOpts {
|
||||
/**
|
||||
@@ -809,7 +810,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
|
||||
|
||||
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
|
||||
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1956,7 +1957,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
|
||||
this.on(ThreadEvent.NewReply, this.onThreadReply);
|
||||
this.on(ThreadEvent.Delete, this.onThreadDelete);
|
||||
this.threadsReady = true;
|
||||
}
|
||||
@@ -2054,7 +2055,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
private onThreadNewReply(thread: Thread): void {
|
||||
private onThreadReply(thread: Thread): void {
|
||||
this.updateThreadRootEvents(thread, false, true);
|
||||
}
|
||||
|
||||
@@ -2112,12 +2113,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
}
|
||||
|
||||
// A thread relation is always only shown in a thread
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
// A thread relation (1st and 2nd order) is always only shown in a thread
|
||||
const threadRootId = event.threadRootId;
|
||||
if (threadRootId != undefined) {
|
||||
return {
|
||||
shouldLiveInRoom: false,
|
||||
shouldLiveInThread: true,
|
||||
threadId: event.threadRootId,
|
||||
threadId: threadRootId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2132,6 +2134,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return this.eventShouldLiveIn(parentEvent, events, roots);
|
||||
}
|
||||
|
||||
if (!event.isRelation()) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Edge case where we know the event is a relation but don't have the parentEvent
|
||||
if (roots?.has(event.relationEventId!)) {
|
||||
return {
|
||||
@@ -2141,9 +2150,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
}
|
||||
|
||||
// We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
|
||||
// We've exhausted all scenarios,
|
||||
// we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
|
||||
// adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInRoom: false,
|
||||
shouldLiveInThread: false,
|
||||
};
|
||||
}
|
||||
@@ -2156,14 +2167,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
|
||||
let thread = this.getThread(threadId);
|
||||
|
||||
if (!thread) {
|
||||
const thread = this.getThread(threadId);
|
||||
if (thread) {
|
||||
thread.addEvents(events, toStartOfTimeline);
|
||||
} else {
|
||||
const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId);
|
||||
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
|
||||
this.createThread(threadId, rootEvent, events, toStartOfTimeline);
|
||||
}
|
||||
|
||||
thread.addEvents(events, toStartOfTimeline);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2700,16 +2710,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @param addLiveEventOptions - addLiveEvent options
|
||||
* @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'.
|
||||
*/
|
||||
public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void;
|
||||
public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise<void>;
|
||||
/**
|
||||
* @deprecated In favor of the overload with `IAddLiveEventOptions`
|
||||
*/
|
||||
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void;
|
||||
public addLiveEvents(
|
||||
public async addLiveEvents(
|
||||
events: MatrixEvent[],
|
||||
duplicateStrategy?: DuplicateStrategy,
|
||||
fromCache?: boolean,
|
||||
): Promise<void>;
|
||||
public async addLiveEvents(
|
||||
events: MatrixEvent[],
|
||||
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
|
||||
fromCache = false,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy;
|
||||
let timelineWasEmpty: boolean | undefined = false;
|
||||
if (typeof duplicateStrategyOrOpts === "object") {
|
||||
@@ -2760,6 +2774,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
timelineWasEmpty,
|
||||
};
|
||||
|
||||
// List of extra events to check for being parents of any relations encountered
|
||||
const neighbouringEvents = [...events];
|
||||
|
||||
for (const event of events) {
|
||||
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
|
||||
this.processLiveEvent(event);
|
||||
@@ -2773,12 +2790,35 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
let { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
event,
|
||||
events,
|
||||
neighbouringEvents,
|
||||
threadRoots,
|
||||
);
|
||||
|
||||
if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) {
|
||||
try {
|
||||
const parentEvent = new MatrixEvent(
|
||||
await this.client.fetchRoomEvent(this.roomId, event.relationEventId!),
|
||||
);
|
||||
neighbouringEvents.push(parentEvent);
|
||||
if (parentEvent.threadRootId) {
|
||||
threadRoots.add(parentEvent.threadRootId);
|
||||
const unsigned = event.getUnsigned();
|
||||
unsigned[UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId;
|
||||
event.setUnsigned(unsigned);
|
||||
}
|
||||
|
||||
({ shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
event,
|
||||
neighbouringEvents,
|
||||
threadRoots,
|
||||
));
|
||||
} catch (e) {
|
||||
logger.error("Failed to load parent event of unhandled relation", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
|
||||
eventsByThread[threadId ?? ""] = [];
|
||||
}
|
||||
@@ -2786,6 +2826,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
if (shouldLiveInRoom) {
|
||||
this.addLiveEvent(event, options);
|
||||
} else if (!shouldLiveInThread && event.isRelation()) {
|
||||
this.relations.aggregateChildEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2796,13 +2838,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
|
||||
public partitionThreadedEvents(
|
||||
events: MatrixEvent[],
|
||||
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] {
|
||||
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], unknownRelations: MatrixEvent[]] {
|
||||
// Indices to the events array, for readability
|
||||
const ROOM = 0;
|
||||
const THREAD = 1;
|
||||
const UNKNOWN_RELATION = 2;
|
||||
if (this.client.supportsThreads()) {
|
||||
const threadRoots = this.findThreadRoots(events);
|
||||
return events.reduce(
|
||||
return events.reduce<[MatrixEvent[], MatrixEvent[], MatrixEvent[]]>(
|
||||
(memo, event: MatrixEvent) => {
|
||||
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
|
||||
event,
|
||||
@@ -2819,13 +2862,17 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
memo[THREAD].push(event);
|
||||
}
|
||||
|
||||
if (!shouldLiveInThread && !shouldLiveInRoom) {
|
||||
memo[UNKNOWN_RELATION].push(event);
|
||||
}
|
||||
|
||||
return memo;
|
||||
},
|
||||
[[] as MatrixEvent[], [] as MatrixEvent[]],
|
||||
[[], [], []],
|
||||
);
|
||||
} else {
|
||||
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
|
||||
return [events as MatrixEvent[], [] as MatrixEvent[]];
|
||||
return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,8 +2882,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
private findThreadRoots(events: MatrixEvent[]): Set<string> {
|
||||
const threadRoots = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
threadRoots.add(event.relationEventId ?? "");
|
||||
const threadRootId = event.threadRootId;
|
||||
if (threadRootId != undefined) {
|
||||
threadRoots.add(threadRootId);
|
||||
}
|
||||
}
|
||||
return threadRoots;
|
||||
|
||||
+98
-23
@@ -38,15 +38,20 @@ export enum ThreadEvent {
|
||||
Delete = "Thread.delete",
|
||||
}
|
||||
|
||||
type EmittedEvents = Exclude<ThreadEvent, ThreadEvent.New> | RoomEvent.Timeline | RoomEvent.TimelineReset;
|
||||
export type ThreadEmittedEvents = Exclude<ThreadEvent, ThreadEvent.New> | RoomEvent.Timeline | RoomEvent.TimelineReset;
|
||||
|
||||
export type EventHandlerMap = {
|
||||
export type ThreadEventHandlerMap = {
|
||||
[ThreadEvent.Update]: (thread: Thread) => void;
|
||||
[ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void;
|
||||
[ThreadEvent.ViewThread]: () => void;
|
||||
[ThreadEvent.Delete]: (thread: Thread) => void;
|
||||
} & EventTimelineSetHandlerMap;
|
||||
|
||||
/**
|
||||
* @deprecated please use ThreadEventHandlerMap instead
|
||||
*/
|
||||
export type EventHandlerMap = ThreadEventHandlerMap;
|
||||
|
||||
interface IThreadOpts {
|
||||
room: Room;
|
||||
client: MatrixClient;
|
||||
@@ -70,7 +75,7 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea
|
||||
}
|
||||
}
|
||||
|
||||
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerMap> {
|
||||
public static hasServerSideSupport = FeatureSupport.None;
|
||||
public static hasServerSideListSupport = FeatureSupport.None;
|
||||
public static hasServerSideFwdPaginationSupport = FeatureSupport.None;
|
||||
@@ -83,7 +88,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
|
||||
private _currentUserParticipated = false;
|
||||
|
||||
private reEmitter: TypedReEmitter<EmittedEvents, EventHandlerMap>;
|
||||
private reEmitter: TypedReEmitter<ThreadEmittedEvents, ThreadEventHandlerMap>;
|
||||
|
||||
private lastEvent: MatrixEvent | undefined;
|
||||
private replyCount = 0;
|
||||
@@ -203,11 +208,33 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
): void => {
|
||||
// Add a synthesized receipt when paginating forward in the timeline
|
||||
if (!toStartOfTimeline) {
|
||||
room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read);
|
||||
const sender = event.getSender();
|
||||
if (sender && room && this.shouldSendLocalEchoReceipt(sender, event)) {
|
||||
room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
|
||||
}
|
||||
}
|
||||
this.onEcho(event, toStartOfTimeline ?? false);
|
||||
};
|
||||
|
||||
private shouldSendLocalEchoReceipt(sender: string, event: MatrixEvent): boolean {
|
||||
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
// Normally we add a local receipt, but if we don't have
|
||||
// recursion support, then events may arrive out of order, so we
|
||||
// only create a receipt if it's after our existing receipt.
|
||||
const oldReceiptEventId = this.getReadReceiptForUserId(sender)?.eventId;
|
||||
if (oldReceiptEventId) {
|
||||
const receiptEvent = this.findEventById(oldReceiptEventId);
|
||||
if (receiptEvent && receiptEvent.getTs() > event.getTs()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private onLocalEcho = (event: MatrixEvent): void => {
|
||||
this.onEcho(event, false);
|
||||
};
|
||||
@@ -236,6 +263,34 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TEMPORARY. Only call this when MSC3981 is not available, and we have some
|
||||
* late-arriving events to insert, because we recursively found them as part
|
||||
* of populating a thread. When we have MSC3981 we won't need it, because
|
||||
* they will all be supplied by the homeserver in one request, and they will
|
||||
* already be in the right order in that response.
|
||||
* This is a copy of addEventToTimeline above, modified to call
|
||||
* insertEventIntoTimeline so this event is inserted into our best guess of
|
||||
* the right place based on timestamp. (We should be using Sync Order but we
|
||||
* don't have it.)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public insertEventIntoTimeline(event: MatrixEvent): void {
|
||||
const eventId = event.getId();
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
// If the event is already in this thread, bail out
|
||||
if (this.findEventById(eventId)) {
|
||||
return;
|
||||
}
|
||||
this.timelineSet.insertEventIntoTimeline(event, this.liveTimeline, this.roomState);
|
||||
|
||||
// As far as we know, timeline should always be the same as events
|
||||
this.timeline = this.events;
|
||||
}
|
||||
|
||||
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||
events.forEach((ev) => this.addEvent(ev, toStartOfTimeline, false));
|
||||
this.updateThreadMetadata();
|
||||
@@ -281,7 +336,14 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
*/
|
||||
this.replayEvents?.push(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
const recursionSupport =
|
||||
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
this.insertEventIntoTimeline(event);
|
||||
} else {
|
||||
this.addEventToTimeline(event, toStartOfTimeline);
|
||||
}
|
||||
}
|
||||
// Apply annotations and replace relations to the relations of the timeline only
|
||||
this.timelineSet.relations?.aggregateParentEvent(event);
|
||||
@@ -460,25 +522,28 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
|
||||
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
|
||||
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
|
||||
if (recursionSupport !== ServerSupport.Unsupported) {
|
||||
if (recursionSupport === ServerSupport.Unsupported) {
|
||||
return Promise.all(
|
||||
events
|
||||
.filter((e) => e.isEncrypted())
|
||||
.map((event: MatrixEvent) => {
|
||||
if (event.isRelation()) return; // skip - relations don't get edits
|
||||
return this.client
|
||||
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
|
||||
events.filter(isAnEncryptedThreadMessage).map(async (event: MatrixEvent) => {
|
||||
try {
|
||||
const relations = await this.client.relations(
|
||||
this.roomId,
|
||||
event.getId()!,
|
||||
RelationType.Replace,
|
||||
event.getType(),
|
||||
{
|
||||
limit: 1,
|
||||
})
|
||||
.then((relations) => {
|
||||
if (relations.events.length) {
|
||||
event.makeReplaced(relations.events[0]);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to load edits for encrypted thread event", e);
|
||||
});
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (relations.events.length) {
|
||||
const editEvent = relations.events[0];
|
||||
event.makeReplaced(editEvent);
|
||||
this.insertEventIntoTimeline(editEvent);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Failed to load edits for encrypted thread event", e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -648,6 +713,16 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an event deserves to have its potential edits fetched.
|
||||
*
|
||||
* @returns true if this event is encrypted and is a message that is part of a
|
||||
* thread - either inside it, or a root.
|
||||
*/
|
||||
function isAnEncryptedThreadMessage(event: MatrixEvent): boolean {
|
||||
return event.isEncrypted() && (event.isRelation(THREAD_RELATION_TYPE.name) || event.isThreadRoot);
|
||||
}
|
||||
|
||||
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
|
||||
"related_by_senders",
|
||||
"io.element.relation_senders",
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
/** Events emitted by EventEmitter itself */
|
||||
export enum EventEmitterEvents {
|
||||
NewListener = "newListener",
|
||||
RemoveListener = "removeListener",
|
||||
@@ -24,10 +25,22 @@ export enum EventEmitterEvents {
|
||||
}
|
||||
|
||||
type AnyListener = (...args: any) => any;
|
||||
|
||||
/** Base class for types mapping from event name to the type of listeners to that event */
|
||||
export type ListenerMap<E extends string> = { [eventName in E]: AnyListener };
|
||||
|
||||
type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void;
|
||||
type EventEmitterErrorListener = (error: Error) => void;
|
||||
|
||||
/**
|
||||
* The expected type of a listener function for a particular event.
|
||||
*
|
||||
* Type parameters:
|
||||
* * `E` - List of all events emitted by the `TypedEventEmitter`. Normally an enum type.
|
||||
* * `A` - A type providing mappings from event names to listener types.
|
||||
* * `T` - The name of the actual event that this listener is for. Normally one of the types in `E` or
|
||||
* {@link EventEmitterEvents}.
|
||||
*/
|
||||
export type Listener<E extends string, A extends ListenerMap<E>, T extends E | EventEmitterEvents> = T extends E
|
||||
? A[T]
|
||||
: T extends EventEmitterEvents
|
||||
@@ -40,12 +53,20 @@ export type Listener<E extends string, A extends ListenerMap<E>, T extends E | E
|
||||
* This makes it much easier for us to distinguish between events, as we now need
|
||||
* to properly type this, so that our events are not stringly-based and prone
|
||||
* to silly typos.
|
||||
*
|
||||
* Type parameters:
|
||||
* * `Events` - List of all events emitted by this `TypedEventEmitter`. Normally an enum type.
|
||||
* * `Arguments` - A {@link ListenerMap} type providing mappings from event names to listener types.
|
||||
* * `SuperclassArguments` - TODO: not really sure. Alternative listener mappings, I think? But only honoured for `.emit`?
|
||||
*/
|
||||
export class TypedEventEmitter<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events>,
|
||||
SuperclassArguments extends ListenerMap<any> = Arguments,
|
||||
> extends EventEmitter {
|
||||
/**
|
||||
* Alias for {@link TypedEventEmitter#on}.
|
||||
*/
|
||||
public addListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -53,32 +74,115 @@ export class TypedEventEmitter<
|
||||
return super.addListener(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously calls each of the listeners registered for the event named
|
||||
* `event`, in the order they were registered, passing the supplied arguments
|
||||
* to each.
|
||||
*
|
||||
* @param event - The name of the event to emit
|
||||
* @param args - Arguments to pass to the listener
|
||||
* @returns `true` if the event had listeners, `false` otherwise.
|
||||
*/
|
||||
public emit<T extends Events>(event: T, ...args: Parameters<SuperclassArguments[T]>): boolean;
|
||||
public emit<T extends Events>(event: T, ...args: Parameters<Arguments[T]>): boolean;
|
||||
public emit<T extends Events>(event: T, ...args: any[]): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to `emit` but calls all listeners within a `Promise.all` and returns the promise chain
|
||||
* @param event - The name of the event to emit
|
||||
* @param args - Arguments to pass to the listener
|
||||
* @returns `true` if the event had listeners, `false` otherwise.
|
||||
*/
|
||||
public async emitPromised<T extends Events>(
|
||||
event: T,
|
||||
...args: Parameters<SuperclassArguments[T]>
|
||||
): Promise<boolean>;
|
||||
public async emitPromised<T extends Events>(event: T, ...args: Parameters<Arguments[T]>): Promise<boolean>;
|
||||
public async emitPromised<T extends Events>(event: T, ...args: any[]): Promise<boolean> {
|
||||
const listeners = this.listeners(event);
|
||||
return Promise.allSettled(listeners.map((l) => l(...args))).then(() => {
|
||||
return listeners.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of listeners listening to the event named `event`.
|
||||
*
|
||||
* @param event - The name of the event being listened for
|
||||
*/
|
||||
public listenerCount(event: Events | EventEmitterEvents): number {
|
||||
return super.listenerCount(event);
|
||||
}
|
||||
|
||||
public listeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["listeners"]> {
|
||||
/**
|
||||
* Returns a copy of the array of listeners for the event named `event`.
|
||||
*/
|
||||
public listeners(event: Events | EventEmitterEvents): Function[] {
|
||||
return super.listeners(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for {@link TypedEventEmitter#removeListener}
|
||||
*/
|
||||
public off<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `listener` function to the end of the listeners array for the
|
||||
* event named `event`.
|
||||
*
|
||||
* No checks are made to see if the `listener` has already been added. Multiple calls
|
||||
* passing the same combination of `event` and `listener` will result in the `listener`
|
||||
* being added, and called, multiple times.
|
||||
*
|
||||
* By default, event listeners are invoked in the order they are added. The
|
||||
* {@link TypedEventEmitter#prependListener} method can be used as an alternative to add the
|
||||
* event listener to the beginning of the listeners array.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public on<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a **one-time** `listener` function for the event named `event`. The
|
||||
* next time `event` is triggered, this listener is removed and then invoked.
|
||||
*
|
||||
* Returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*
|
||||
* By default, event listeners are invoked in the order they are added.
|
||||
* The {@link TypedEventEmitter#prependOnceListener} method can be used as an alternative to add the
|
||||
* event listener to the beginning of the listeners array.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public once<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `listener` function to the _beginning_ of the listeners array for the
|
||||
* event named `event`.
|
||||
*
|
||||
* No checks are made to see if the `listener` has already been added. Multiple calls
|
||||
* passing the same combination of `event` and `listener` will result in the `listener`
|
||||
* being added, and called, multiple times.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public prependListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -86,6 +190,15 @@ export class TypedEventEmitter<
|
||||
return super.prependListener(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a **one-time**`listener` function for the event named `event` to the _beginning_ of the listeners array.
|
||||
* The next time `event` is triggered, this listener is removed, and then invoked.
|
||||
*
|
||||
* @param event - The name of the event.
|
||||
* @param listener - The callback function
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public prependOnceListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -93,10 +206,25 @@ export class TypedEventEmitter<
|
||||
return super.prependOnceListener(event, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all listeners, or those of the specified `event`.
|
||||
*
|
||||
* It is bad practice to remove listeners added elsewhere in the code,
|
||||
* particularly when the `EventEmitter` instance was created by some other
|
||||
* component or module (e.g. sockets or file streams).
|
||||
*
|
||||
* @param event - The name of the event. If undefined, all listeners everywhere are removed.
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public removeAllListeners(event?: Events | EventEmitterEvents): this {
|
||||
return super.removeAllListeners(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified `listener` from the listener array for the event named `event`.
|
||||
*
|
||||
* @returns a reference to the `EventEmitter`, so that calls can be chained.
|
||||
*/
|
||||
public removeListener<T extends Events | EventEmitterEvents>(
|
||||
event: T,
|
||||
listener: Listener<Events, Arguments, T>,
|
||||
@@ -104,7 +232,11 @@ export class TypedEventEmitter<
|
||||
return super.removeListener(event, listener);
|
||||
}
|
||||
|
||||
public rawListeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["rawListeners"]> {
|
||||
/**
|
||||
* Returns a copy of the array of listeners for the event named `eventName`,
|
||||
* including any wrappers (such as those created by `.once()`).
|
||||
*/
|
||||
public rawListeners(event: Events | EventEmitterEvents): Function[] {
|
||||
return super.rawListeners(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export enum OidcDiscoveryError {
|
||||
NotSupported = "OIDC authentication not supported",
|
||||
Misconfigured = "OIDC is misconfigured",
|
||||
General = "Something went wrong with OIDC discovery",
|
||||
OpSupport = "Configured OIDC OP does not support required functions",
|
||||
}
|
||||
|
||||
export type ValidatedIssuerConfig = {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
registrationEndpoint?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates MSC2965 m.authentication config
|
||||
* Returns valid configuration
|
||||
* @param wellKnown - client well known as returned from ./well-known/client/matrix
|
||||
* @returns config - when present and valid
|
||||
* @throws when config is not found or invalid
|
||||
*/
|
||||
export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): IDelegatedAuthConfig => {
|
||||
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown);
|
||||
|
||||
if (!authentication) {
|
||||
throw new Error(OidcDiscoveryError.NotSupported);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof authentication.issuer === "string" &&
|
||||
(!authentication.hasOwnProperty("account") || typeof authentication.account === "string")
|
||||
) {
|
||||
return {
|
||||
issuer: authentication.issuer,
|
||||
account: authentication.account,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(OidcDiscoveryError.Misconfigured);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
const requiredStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
|
||||
if (!wellKnown[key] || !optionalStringProperty(wellKnown, key)) {
|
||||
logger.error(`OIDC issuer configuration: ${key} is invalid`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
|
||||
if (!!wellKnown[key] && typeof wellKnown[key] !== "string") {
|
||||
logger.error(`OIDC issuer configuration: ${key} is invalid`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, value: any): boolean => {
|
||||
const array = wellKnown[key];
|
||||
if (!array || !Array.isArray(array) || !array.includes(value)) {
|
||||
logger.error(`OIDC issuer configuration: ${key} is invalid. ${value} is required.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates issue `.well-known/openid-configuration`
|
||||
* As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
* validates that OP is compatible with Element's OIDC flow
|
||||
* @param wellKnown - json object
|
||||
* @returns valid issuer config
|
||||
* @throws Error - when issuer config is not found or is invalid
|
||||
*/
|
||||
export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => {
|
||||
if (!isRecord(wellKnown)) {
|
||||
logger.error("Issuer configuration not found or malformed");
|
||||
throw new Error(OidcDiscoveryError.OpSupport);
|
||||
}
|
||||
|
||||
const isInvalid = [
|
||||
requiredStringProperty(wellKnown, "authorization_endpoint"),
|
||||
requiredStringProperty(wellKnown, "token_endpoint"),
|
||||
optionalStringProperty(wellKnown, "registration_endpoint"),
|
||||
requiredArrayValue(wellKnown, "response_types_supported", "code"),
|
||||
requiredArrayValue(wellKnown, "grant_types_supported", "authorization_code"),
|
||||
requiredArrayValue(wellKnown, "code_challenge_methods_supported", "S256"),
|
||||
].some((isValid) => !isValid);
|
||||
|
||||
if (!isInvalid) {
|
||||
return {
|
||||
authorizationEndpoint: wellKnown["authorization_endpoint"],
|
||||
tokenEndpoint: wellKnown["token_endpoint"],
|
||||
registrationEndpoint: wellKnown["registration_endpoint"],
|
||||
} as ValidatedIssuerConfig;
|
||||
}
|
||||
|
||||
logger.error("Issuer configuration not valid");
|
||||
throw new Error(OidcDiscoveryError.OpSupport);
|
||||
};
|
||||
@@ -118,6 +118,26 @@ export class ReceiptAccumulator {
|
||||
eventId,
|
||||
};
|
||||
|
||||
// In a world that supports threads, read receipts normally have
|
||||
// a `thread_id` which is either the thread they belong in or
|
||||
// `MAIN_ROOM_TIMELINE`, so we normally use `setThreaded(...)`
|
||||
// here. The `MAIN_ROOM_TIMELINE` is just treated as another
|
||||
// thread.
|
||||
//
|
||||
// We still encounter read receipts that are "unthreaded"
|
||||
// (missing the `thread_id` property). These come from clients
|
||||
// that don't support threads, and from threaded clients that
|
||||
// are doing a "Mark room as read" operation. Unthreaded
|
||||
// receipts mark everything "before" them as read, in all
|
||||
// threads, where "before" means in Sync Order i.e. the order
|
||||
// the events were received from the homeserver in a sync.
|
||||
// [Note: we have some bugs where we use timestamp order instead
|
||||
// of Sync Order, because we don't correctly remember the Sync
|
||||
// Order. See #3325.]
|
||||
//
|
||||
// Calling the wrong method will cause incorrect behavior like
|
||||
// messages re-appearing as "new" when you already read them
|
||||
// previously.
|
||||
if (!data.thread_id) {
|
||||
this.setUnthreaded(userId, receipt);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { OlmMachine, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-js";
|
||||
|
||||
import { BootstrapCrossSigningOpts } from "../crypto-api";
|
||||
import { logger } from "../logger";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { UIAuthCallback } from "../interactive-auth";
|
||||
|
||||
/** Manages the cross-signing keys for our own user.
|
||||
*/
|
||||
export class CrossSigningIdentity {
|
||||
public constructor(
|
||||
private readonly olmMachine: OlmMachine,
|
||||
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialise our cross-signing keys by creating new keys if they do not exist, and uploading to the server
|
||||
*/
|
||||
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
if (opts.setupNewCrossSigning) {
|
||||
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
const olmDeviceStatus: CrossSigningStatus = await this.olmMachine.crossSigningStatus();
|
||||
const privateKeysInSecretStorage = false; // TODO
|
||||
const olmDeviceHasKeys =
|
||||
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
|
||||
|
||||
// Log all relevant state for easier parsing of debug logs.
|
||||
logger.log("bootStrapCrossSigning: starting", {
|
||||
setupNewCrossSigning: opts.setupNewCrossSigning,
|
||||
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
|
||||
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
|
||||
olmDeviceHasSelfSigning: olmDeviceStatus.hasSelfSigning,
|
||||
privateKeysInSecretStorage,
|
||||
});
|
||||
|
||||
if (!olmDeviceHasKeys && !privateKeysInSecretStorage) {
|
||||
logger.log(
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
|
||||
);
|
||||
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
|
||||
} else if (olmDeviceHasKeys) {
|
||||
logger.log("bootStrapCrossSigning: Olm device has private keys: exporting to secret storage");
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
} else if (privateKeysInSecretStorage) {
|
||||
logger.log(
|
||||
"bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
|
||||
"in secret storage, reading storage and caching locally",
|
||||
);
|
||||
throw new Error("TODO");
|
||||
}
|
||||
|
||||
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
|
||||
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
|
||||
logger.log("bootStrapCrossSigning: complete");
|
||||
}
|
||||
|
||||
/** Reset our cross-signing keys
|
||||
*
|
||||
* This method will:
|
||||
* * Tell the OlmMachine to create new keys
|
||||
* * Upload the new public keys and the device signature to the server
|
||||
* * Upload the private keys to SSSS, if it is set up
|
||||
*/
|
||||
private async resetCrossSigning(authUploadDeviceSigningKeys?: UIAuthCallback<void>): Promise<void> {
|
||||
const outgoingRequests: Array<OutgoingRequest> = await this.olmMachine.bootstrapCrossSigning(true);
|
||||
|
||||
logger.log("bootStrapCrossSigning: publishing keys to server");
|
||||
for (const req of outgoingRequests) {
|
||||
await this.outgoingRequestProcessor.makeOutgoingRequest(req, authUploadDeviceSigningKeys);
|
||||
}
|
||||
await this.exportCrossSigningKeysToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the cross-signing keys from the olm machine and save them to secret storage, if it is configured
|
||||
*
|
||||
* (If secret storage is *not* configured, we assume that the export will happen when it is set up)
|
||||
*/
|
||||
private async exportCrossSigningKeysToStorage(): Promise<void> {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,20 @@ import { RoomEncryptor } from "./RoomEncryptor";
|
||||
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
|
||||
import { KeyClaimManager } from "./KeyClaimManager";
|
||||
import { MapWithDefault } from "../utils";
|
||||
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
|
||||
import {
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DeviceVerificationStatus,
|
||||
ImportRoomKeyProgressData,
|
||||
ImportRoomKeysOpts,
|
||||
} from "../crypto-api";
|
||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
|
||||
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
import { CrossSigningKey } from "../crypto/api";
|
||||
import { CrossSigningIdentity } from "./CrossSigningIdentity";
|
||||
import { secretStorageContainsCrossSigningKeys } from "./secret-storage";
|
||||
|
||||
/**
|
||||
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
|
||||
@@ -56,6 +64,7 @@ export class RustCrypto implements CryptoBackend {
|
||||
private eventDecryptor: EventDecryptor;
|
||||
private keyClaimManager: KeyClaimManager;
|
||||
private outgoingRequestProcessor: OutgoingRequestProcessor;
|
||||
private crossSigningIdentity: CrossSigningIdentity;
|
||||
|
||||
public constructor(
|
||||
/** The `OlmMachine` from the underlying rust crypto sdk. */
|
||||
@@ -69,17 +78,18 @@ export class RustCrypto implements CryptoBackend {
|
||||
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
|
||||
|
||||
/** The local user's User ID. */
|
||||
_userId: string,
|
||||
private readonly userId: string,
|
||||
|
||||
/** The local user's Device ID. */
|
||||
_deviceId: string,
|
||||
|
||||
/** Interface to server-side secret storage */
|
||||
_secretStorage: ServerSideSecretStorage,
|
||||
private readonly secretStorage: ServerSideSecretStorage,
|
||||
) {
|
||||
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
|
||||
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
|
||||
this.eventDecryptor = new EventDecryptor(olmMachine);
|
||||
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -203,8 +213,22 @@ export class RustCrypto implements CryptoBackend {
|
||||
}
|
||||
|
||||
public async exportRoomKeys(): Promise<IMegolmSessionData[]> {
|
||||
// TODO
|
||||
return [];
|
||||
const raw = await this.olmMachine.exportRoomKeys(() => true);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
public async importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
|
||||
// TODO when backup support will be added we would need to expose the `from_backup` flag in the bindings
|
||||
const jsonKeys = JSON.stringify(keys);
|
||||
await this.olmMachine.importRoomKeys(jsonKeys, (progress: BigInt, total: BigInt) => {
|
||||
const importOpt: ImportRoomKeyProgressData = {
|
||||
total: Number(total),
|
||||
successes: Number(progress),
|
||||
stage: "load_keys",
|
||||
failures: 0,
|
||||
};
|
||||
opts?.progressCallback?.(importOpt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +346,15 @@ export class RustCrypto implements CryptoBackend {
|
||||
* Implementation of {@link CryptoApi#isCrossSigningReady}
|
||||
*/
|
||||
public async isCrossSigningReady(): Promise<boolean> {
|
||||
return false;
|
||||
const { publicKeysOnDevice, privateKeysInSecretStorage, privateKeysCachedLocally } =
|
||||
await this.getCrossSigningStatus();
|
||||
const hasKeysInCache =
|
||||
Boolean(privateKeysCachedLocally.masterKey) &&
|
||||
Boolean(privateKeysCachedLocally.selfSigningKey) &&
|
||||
Boolean(privateKeysCachedLocally.userSigningKey);
|
||||
|
||||
// The cross signing is ready if the public and private keys are available
|
||||
return publicKeysOnDevice && (hasKeysInCache || privateKeysInSecretStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,7 +369,7 @@ export class RustCrypto implements CryptoBackend {
|
||||
* Implementation of {@link CryptoApi#boostrapCrossSigning}
|
||||
*/
|
||||
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
|
||||
logger.log("Cross-signing ready");
|
||||
await this.crossSigningIdentity.bootstrapCrossSigning(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,6 +379,32 @@ export class RustCrypto implements CryptoBackend {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link CryptoApi#getCrossSigningStatus}
|
||||
*/
|
||||
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
|
||||
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | null = await this.olmMachine.getIdentity(
|
||||
new RustSdkCryptoJs.UserId(this.userId),
|
||||
);
|
||||
const publicKeysOnDevice =
|
||||
Boolean(userIdentity?.masterKey) &&
|
||||
Boolean(userIdentity?.selfSigningKey) &&
|
||||
Boolean(userIdentity?.userSigningKey);
|
||||
const privateKeysInSecretStorage = await secretStorageContainsCrossSigningKeys(this.secretStorage);
|
||||
const crossSigningStatus: RustSdkCryptoJs.CrossSigningStatus | null =
|
||||
await this.olmMachine.crossSigningStatus();
|
||||
|
||||
return {
|
||||
publicKeysOnDevice,
|
||||
privateKeysInSecretStorage,
|
||||
privateKeysCachedLocally: {
|
||||
masterKey: Boolean(crossSigningStatus?.hasMaster),
|
||||
userSigningKey: Boolean(crossSigningStatus?.hasUserSigning),
|
||||
selfSigningKey: Boolean(crossSigningStatus?.hasSelfSigning),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// SyncCryptoCallbacks implementation
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ServerSideSecretStorage } from "../secret-storage";
|
||||
|
||||
/**
|
||||
* Check that the private cross signing keys (master, self signing, user signing) are stored in the secret storage and encrypted with the same secret storage key.
|
||||
*
|
||||
* @param secretStorage - The secret store using account data
|
||||
* @returns True if the cross-signing keys are all stored and encrypted with the same secret storage key.
|
||||
*/
|
||||
export async function secretStorageContainsCrossSigningKeys(secretStorage: ServerSideSecretStorage): Promise<boolean> {
|
||||
// Check if the master cross-signing key is stored in secret storage
|
||||
const secretStorageMasterKeys = await secretStorage.isStored("m.cross_signing.master");
|
||||
|
||||
// Master key not stored
|
||||
if (!secretStorageMasterKeys) return false;
|
||||
|
||||
// Get the user signing keys stored into the secret storage
|
||||
const secretStorageUserSigningKeys = (await secretStorage.isStored(`m.cross_signing.user_signing`)) || {};
|
||||
// Get the self signing keys stored into the secret storage
|
||||
const secretStorageSelfSigningKeys = (await secretStorage.isStored(`m.cross_signing.self_signing`)) || {};
|
||||
|
||||
// Check that one of the secret storage keys used to encrypt the master key was also used to encrypt the user-signing and self-signing keys
|
||||
return Object.keys(secretStorageMasterKeys).some(
|
||||
(secretStorageKey) =>
|
||||
secretStorageUserSigningKeys[secretStorageKey] && secretStorageSelfSigningKeys[secretStorageKey],
|
||||
);
|
||||
}
|
||||
@@ -376,7 +376,7 @@ export class SlidingSyncSdk {
|
||||
});
|
||||
}
|
||||
|
||||
private onRoomData(roomId: string, roomData: MSC3575RoomData): void {
|
||||
private async onRoomData(roomId: string, roomData: MSC3575RoomData): Promise<void> {
|
||||
let room = this.client.store.getRoom(roomId);
|
||||
if (!room) {
|
||||
if (!roomData.initial) {
|
||||
@@ -385,7 +385,7 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
room = _createAndReEmitRoom(this.client, roomId, this.opts);
|
||||
}
|
||||
this.processRoomData(this.client, room, roomData);
|
||||
await this.processRoomData(this.client, room!, roomData);
|
||||
}
|
||||
|
||||
private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error): void {
|
||||
@@ -628,7 +628,7 @@ export class SlidingSyncSdk {
|
||||
|
||||
if (roomData.invite_state) {
|
||||
const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
|
||||
this.injectRoomEvents(room, inviteStateEvents);
|
||||
await this.injectRoomEvents(room, inviteStateEvents);
|
||||
if (roomData.initial) {
|
||||
room.recalculate();
|
||||
this.client.store.storeRoom(room);
|
||||
@@ -700,7 +700,7 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
} */
|
||||
|
||||
this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live);
|
||||
await this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live);
|
||||
|
||||
// we deliberately don't add ephemeral events to the timeline
|
||||
room.addEphemeralEvents(ephemeralEvents);
|
||||
@@ -747,12 +747,12 @@ export class SlidingSyncSdk {
|
||||
* @param numLive - the number of events in timelineEventList which just happened,
|
||||
* supplied from the server.
|
||||
*/
|
||||
public injectRoomEvents(
|
||||
public async injectRoomEvents(
|
||||
room: Room,
|
||||
stateEventList: MatrixEvent[],
|
||||
timelineEventList?: MatrixEvent[],
|
||||
numLive?: number,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
timelineEventList = timelineEventList || [];
|
||||
stateEventList = stateEventList || [];
|
||||
numLive = numLive || 0;
|
||||
@@ -811,11 +811,11 @@ export class SlidingSyncSdk {
|
||||
// if the timeline has any state events in it.
|
||||
// This also needs to be done before running push rules on the events as they need
|
||||
// to be decorated with sender etc.
|
||||
room.addLiveEvents(timelineEventList, {
|
||||
await room.addLiveEvents(timelineEventList, {
|
||||
fromCache: true,
|
||||
});
|
||||
if (liveTimelineEvents.length > 0) {
|
||||
room.addLiveEvents(liveTimelineEvents, {
|
||||
await room.addLiveEvents(liveTimelineEvents, {
|
||||
fromCache: false,
|
||||
});
|
||||
}
|
||||
|
||||
+6
-6
@@ -326,7 +326,7 @@ export enum SlidingSyncEvent {
|
||||
}
|
||||
|
||||
export type SlidingSyncEventHandlerMap = {
|
||||
[SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void;
|
||||
[SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => Promise<void> | void;
|
||||
[SlidingSyncEvent.Lifecycle]: (
|
||||
state: SlidingSyncState,
|
||||
resp: MSC3575SlidingSyncResponse | null,
|
||||
@@ -567,14 +567,14 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
* @param roomId - The room which received some data.
|
||||
* @param roomData - The raw sliding sync response JSON.
|
||||
*/
|
||||
private invokeRoomDataListeners(roomId: string, roomData: MSC3575RoomData): void {
|
||||
private async invokeRoomDataListeners(roomId: string, roomData: MSC3575RoomData): Promise<void> {
|
||||
if (!roomData.required_state) {
|
||||
roomData.required_state = [];
|
||||
}
|
||||
if (!roomData.timeline) {
|
||||
roomData.timeline = [];
|
||||
}
|
||||
this.emit(SlidingSyncEvent.RoomData, roomId, roomData);
|
||||
await this.emitPromised(SlidingSyncEvent.RoomData, roomId, roomData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -923,9 +923,9 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
}
|
||||
this.onPreExtensionsResponse(resp.extensions);
|
||||
|
||||
Object.keys(resp.rooms).forEach((roomId) => {
|
||||
this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
|
||||
});
|
||||
for (const roomId in resp.rooms) {
|
||||
await this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
|
||||
}
|
||||
|
||||
const listKeysWithUpdates: Set<string> = new Set();
|
||||
if (!doNotUpdateList) {
|
||||
|
||||
@@ -245,4 +245,9 @@ export interface IStore {
|
||||
* Removes a specific batch of to-device messages from the queue
|
||||
*/
|
||||
removeToDeviceBatch(id: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the store and perform any appropriate cleanup
|
||||
*/
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface IIndexedDBBackend {
|
||||
saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void>;
|
||||
getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null>;
|
||||
removeToDeviceBatch(id: number): Promise<void>;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
|
||||
export type UserTuple = [userId: string, presenceEvent: Partial<IEvent>];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user