diff --git a/.github/workflows/docs-pr-netlify.yaml b/.github/workflows/docs-pr-netlify.yaml index c53f981b6ea..b5d28971740 100644 --- a/.github/workflows/docs-pr-netlify.yaml +++ b/.github/workflows/docs-pr-netlify.yaml @@ -14,7 +14,7 @@ jobs: # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@e6e25ac3a2b93187502a8be1ef9e9603afc34925 # v2.24.2 + uses: dawidd6/action-download-artifact@bd10f381a96414ce2b13a11bfa89902ba7cea07f # v2.24.3 with: workflow: static_analysis.yml run_id: ${{ github.event.workflow_run.id }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 61aab404856..48434193aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +Changes in [23.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.2.0) (2023-01-31) +================================================================================================== + +## ✨ Features + * Implement decryption via the rust sdk ([\#3074](https://github.com/matrix-org/matrix-js-sdk/pull/3074)). + * Handle edits which are bundled with an event, per MSC3925 ([\#3045](https://github.com/matrix-org/matrix-js-sdk/pull/3045)). + +## 🐛 Bug Fixes + * Add null check for our own member event ([\#3082](https://github.com/matrix-org/matrix-js-sdk/pull/3082)). + * Handle group call getting initialised twice in quick succession ([\#3078](https://github.com/matrix-org/matrix-js-sdk/pull/3078)). Fixes vector-im/element-call#847. + * Correctly handle limited sync responses by resetting the thread timeline ([\#3056](https://github.com/matrix-org/matrix-js-sdk/pull/3056)). Fixes vector-im/element-web#23952. Contributed by @justjanne. + * Fix failure to start in firefox private browser ([\#3058](https://github.com/matrix-org/matrix-js-sdk/pull/3058)). Fixes vector-im/element-web#24216. + * Fix spurious "Decryption key withheld" messages ([\#3061](https://github.com/matrix-org/matrix-js-sdk/pull/3061)). Fixes vector-im/element-web#23803. + * Fix browser entrypoint ([\#3051](https://github.com/matrix-org/matrix-js-sdk/pull/3051)). Fixes #3013. + Changes in [23.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.1.1) (2023-01-20) ================================================================================================== diff --git a/package.json b/package.json index ab0427a88de..32383130a77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "23.1.1", + "version": "23.2.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=16.0.0" @@ -33,7 +33,7 @@ "matrix-org" ], "main": "./lib/index.js", - "browser": "./lib/browser-index.js", + "browser": "./src/browser-index.ts", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.ts", "matrix_lib_main": "./lib/index.js", @@ -98,7 +98,7 @@ "browserify": "^17.0.0", "docdash": "^2.0.0", "domexception": "^4.0.0", - "eslint": "8.29.0", + "eslint": "8.31.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^3.5.1", @@ -114,7 +114,7 @@ "jest-localstorage-mock": "^2.4.6", "jest-mock": "^29.0.0", "matrix-mock-request": "^2.5.0", - "prettier": "2.8.1", + "prettier": "2.8.2", "rimraf": "^3.0.2", "terser": "^5.5.1", "tsify": "^5.0.2", diff --git a/spec/TestClient.ts b/spec/TestClient.ts index 3e672bfb877..c35aff30fc8 100644 --- a/spec/TestClient.ts +++ b/spec/TestClient.ts @@ -115,13 +115,12 @@ export class TestClient { } /** - * Set up expectations that the client will upload device keys. + * Set up expectations that the client will upload device keys (and possibly one-time keys) */ public expectDeviceKeyUpload() { this.httpBackend .when("POST", "/keys/upload") .respond(200, (_path, content) => { - expect(content.one_time_keys).toBe(undefined); expect(content.device_keys).toBeTruthy(); logger.log(this + ": received device keys"); @@ -129,7 +128,17 @@ export class TestClient { expect(Object.keys(this.oneTimeKeys!).length).toEqual(0); this.deviceKeys = content.device_keys; - return { one_time_key_counts: { signed_curve25519: 0 } }; + + // the first batch of one-time keys may be uploaded at the same time. + if (content.one_time_keys) { + logger.log(`${this}: received ${Object.keys(content.one_time_keys).length} one-time keys`); + this.oneTimeKeys = content.one_time_keys; + } + return { + one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys!).length, + }, + }; }); } diff --git a/spec/integ/megolm-integ.spec.ts b/spec/integ/crypto.spec.ts similarity index 85% rename from spec/integ/megolm-integ.spec.ts rename to spec/integ/crypto.spec.ts index 1bdf3a09f47..910987b455a 100644 --- a/spec/integ/megolm-integ.spec.ts +++ b/spec/integ/crypto.spec.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019-2022 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -17,6 +17,8 @@ limitations under the License. import anotherjson from "another-json"; import MockHttpBackend from "matrix-mock-request"; +import "fake-indexeddb/auto"; +import { IDBFactory } from "fake-indexeddb"; import * as testUtils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -38,9 +40,17 @@ import { } from "../../src/matrix"; import { IDeviceKeys } from "../../src/crypto/dehydration"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; +import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils"; const ROOM_ID = "!room:id"; +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(); +}); + // start an Olm session with a given recipient async function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise { const keys = await recipientTestClient.awaitOneTimeKeyUpload(); @@ -59,13 +69,21 @@ interface ToDeviceEvent { type: string; } -// encrypt an event with olm +/** encrypt an event with an existing olm session */ function encryptOlmEvent(opts: { + /** the sender's user id */ sender?: string; + /** the sender's curve25519 key */ senderKey: string; + /** the sender's ed25519 key */ + senderSigningKey: string; + /** the olm session to use for encryption */ p2pSession: Olm.Session; + /** the recipient client */ recipient: TestClient; + /** the payload of the message */ plaincontent?: object; + /** the event type of the payload */ plaintype?: string; }): ToDeviceEvent { expect(opts.senderKey).toBeTruthy(); @@ -78,6 +96,9 @@ function encryptOlmEvent(opts: { recipient_keys: { ed25519: opts.recipient.getSigningKey(), }, + keys: { + ed25519: opts.senderSigningKey, + }, sender: opts.sender || "@bob:xyz", type: opts.plaintype || "m.test", }; @@ -101,7 +122,7 @@ function encryptMegolmEvent(opts: { groupSession: Olm.OutboundGroupSession; plaintext?: Partial; room_id?: string; -}): Pick { +}): IEvent { expect(opts.senderKey).toBeTruthy(); expect(opts.groupSession).toBeTruthy(); @@ -119,30 +140,44 @@ function encryptMegolmEvent(opts: { expect(opts.room_id).toBeTruthy(); plaintext.room_id = opts.room_id; } + return encryptMegolmEventRawPlainText({ senderKey: opts.senderKey, groupSession: opts.groupSession, plaintext }); +} +function encryptMegolmEventRawPlainText(opts: { + senderKey: string; + groupSession: Olm.OutboundGroupSession; + plaintext: Partial; +}): IEvent { return { - event_id: "test_megolm_event_" + Math.random(), + event_id: "$test_megolm_event_" + Math.random(), + sender: "@not_the_real_sender:example.com", + origin_server_ts: 1672944778000, content: { algorithm: "m.megolm.v1.aes-sha2", - ciphertext: opts.groupSession.encrypt(JSON.stringify(plaintext)), + ciphertext: opts.groupSession.encrypt(JSON.stringify(opts.plaintext)), device_id: "testDevice", sender_key: opts.senderKey, session_id: opts.groupSession.session_id(), }, type: "m.room.encrypted", + unsigned: {}, }; } -// build an encrypted room_key event to share a group session +/** build an encrypted room_key event to share a group session, using an existing olm session */ function encryptGroupSessionKey(opts: { - senderKey: string; recipient: TestClient; + /** sender's olm account */ + olmAccount: Olm.Account; + /** sender's olm session with the recipient */ p2pSession: Olm.Session; groupSession: Olm.OutboundGroupSession; room_id?: string; }): Partial { + const senderKeys = JSON.parse(opts.olmAccount.identity_keys()); return encryptOlmEvent({ - senderKey: opts.senderKey, + senderKey: senderKeys.curve25519, + senderSigningKey: senderKeys.ed25519, recipient: opts.recipient, p2pSession: opts.p2pSession, plaincontent: { @@ -219,6 +254,7 @@ async function establishOlmSession(testClient: TestClient, peerOlmAccount: Olm.A const p2pSession = await createOlmSession(peerOlmAccount, testClient); const olmEvent = encryptOlmEvent({ senderKey: peerE2EKeys.curve25519, + senderSigningKey: peerE2EKeys.ed25519, recipient: testClient, p2pSession: p2pSession, }); @@ -315,11 +351,17 @@ async function expectSendMegolmMessage( return JSON.parse(r.plaintext); } -describe("megolm", () => { +describe.each(Object.entries(CRYPTO_BACKENDS))("megolm (%s)", (backend: string, initCrypto: InitCrypto) => { if (!global.Olm) { + // currently we use libolm to implement the crypto in the tests, so need it to be present. logger.warn("not running megolm tests: Olm not present"); return; } + + // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the + // Rust backend. Once we have full support in the rust sdk, it will go away. + const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; + const Olm = global.Olm; let testOlmAccount = {} as unknown as Olm.Account; @@ -384,29 +426,38 @@ describe("megolm", () => { beforeEach(async () => { aliceTestClient = new TestClient("@alice:localhost", "xzcvb", "akjgkrgjs"); - await aliceTestClient.client.initCrypto(); + await initCrypto(aliceTestClient.client); + // create a test olm device which we will use to communicate with alice. We use libolm to implement this. + await Olm.init(); testOlmAccount = new Olm.Account(); testOlmAccount.create(); const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); testSenderKey = testE2eKeys.curve25519; }); - afterEach(() => aliceTestClient.stop()); + afterEach(async () => { + await aliceTestClient.stop(); + }); it("Alice receives a megolm message", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + } + const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ - senderKey: testSenderKey, recipient: aliceTestClient, + olmAccount: testOlmAccount, p2pSession: p2pSession, groupSession: groupSession, room_id: ROOM_ID, @@ -444,20 +495,25 @@ describe("megolm", () => { expect(decryptedEvent.getContent().body).toEqual("42"); }); - it("Alice receives a megolm message before the session keys", async () => { + oldBackendOnly("Alice receives a megolm message before the session keys", async () => { // https://github.com/vector-im/element-web/issues/2273 await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + } + const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - // make the room_key event, but don't send it yet const roomKeyEncrypted = encryptGroupSessionKey({ - senderKey: testSenderKey, recipient: aliceTestClient, + olmAccount: testOlmAccount, p2pSession: p2pSession, groupSession: groupSession, room_id: ROOM_ID, @@ -507,17 +563,22 @@ describe("megolm", () => { it("Alice gets a second room_key message", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + } + const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - // make the room_key event const roomKeyEncrypted1 = encryptGroupSessionKey({ - senderKey: testSenderKey, recipient: aliceTestClient, + olmAccount: testOlmAccount, p2pSession: p2pSession, groupSession: groupSession, room_id: ROOM_ID, @@ -533,8 +594,8 @@ describe("megolm", () => { // make a second room_key event now that we have advanced the group // session. const roomKeyEncrypted2 = encryptGroupSessionKey({ - senderKey: testSenderKey, recipient: aliceTestClient, + olmAccount: testOlmAccount, p2pSession: p2pSession, groupSession: groupSession, room_id: ROOM_ID, @@ -572,7 +633,7 @@ describe("megolm", () => { expect(event.getContent().body).toEqual("42"); }); - it("Alice sends a megolm message", async () => { + oldBackendOnly("Alice sends a megolm message", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); @@ -615,7 +676,7 @@ describe("megolm", () => { ]); }); - it("We shouldn't attempt to send to blocked devices", async () => { + oldBackendOnly("We shouldn't attempt to send to blocked devices", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); await establishOlmSession(aliceTestClient, testOlmAccount); @@ -659,7 +720,7 @@ describe("megolm", () => { expect(() => aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toThrowError("encryption disabled"); }); - it("should permit sending to unknown devices", async () => { + oldBackendOnly("should permit sending to unknown devices", async () => { expect(aliceTestClient.client.getGlobalErrorOnUnknownDevices()).toBeTruthy(); aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); @@ -717,7 +778,7 @@ describe("megolm", () => { ); }); - it("should disable sending to unverified devices", async () => { + oldBackendOnly("should disable sending to unverified devices", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); @@ -775,7 +836,7 @@ describe("megolm", () => { }); }); - it("We should start a new megolm session when a device is blocked", async () => { + oldBackendOnly("We should start a new megolm session when a device is blocked", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); const p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount); @@ -833,7 +894,7 @@ describe("megolm", () => { }); // https://github.com/vector-im/element-web/issues/2676 - it("Alice should send to her other devices", async () => { + oldBackendOnly("Alice should send to her other devices", async () => { // for this test, we make the testOlmAccount be another of Alice's devices. // it ought to get included in messages Alice sends. await aliceTestClient.start(); @@ -914,7 +975,7 @@ describe("megolm", () => { expect(decrypted.content?.body).toEqual("test"); }); - it("Alice should wait for device list to complete when sending a megolm message", async () => { + oldBackendOnly("Alice should wait for device list to complete when sending a megolm message", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); await establishOlmSession(aliceTestClient, testOlmAccount); @@ -944,22 +1005,27 @@ describe("megolm", () => { await Promise.all([downloadPromise, sendPromise]); }); - it("Alice exports megolm keys and imports them to a new device", async () => { + oldBackendOnly("Alice exports megolm keys and imports them to a new device", async () => { aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + } + // establish an olm session with alice const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ - senderKey: testSenderKey, recipient: aliceTestClient, + olmAccount: testOlmAccount, p2pSession: p2pSession, groupSession: groupSession, room_id: ROOM_ID, @@ -999,11 +1065,15 @@ describe("megolm", () => { aliceTestClient.stop(); aliceTestClient = new TestClient("@alice:localhost", "device2", "access_token2"); - await aliceTestClient.client.initCrypto(); + await initCrypto(aliceTestClient.client); await aliceTestClient.client.importRoomKeys(exported); await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + } const syncResponse = { next_batch: 1, @@ -1079,17 +1149,22 @@ describe("megolm", () => { it("Alice can decrypt a message with falsey content", async () => { await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto.deviceList.getUserByIdentityKey = () => "@bob:xyz"; + } + const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - // make the room_key event const roomKeyEncrypted = encryptGroupSessionKey({ - senderKey: testSenderKey, recipient: aliceTestClient, + olmAccount: testOlmAccount, p2pSession: p2pSession, groupSession: groupSession, room_id: ROOM_ID, @@ -1101,17 +1176,11 @@ describe("megolm", () => { room_id: ROOM_ID, }; - const messageEncrypted = { - event_id: "test_megolm_event", - content: { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: groupSession.encrypt(JSON.stringify(plaintext)), - device_id: "testDevice", - sender_key: testSenderKey, - session_id: groupSession.session_id(), - }, - type: "m.room.encrypted", - }; + const messageEncrypted = encryptMegolmEventRawPlainText({ + senderKey: testSenderKey, + groupSession: groupSession, + plaintext: plaintext, + }); // Alice gets both the events in a single sync const syncResponse = { @@ -1138,79 +1207,21 @@ describe("megolm", () => { expect(decryptedEvent.getClearContent()).toBeUndefined(); }); - it("should successfully decrypt bundled redaction events that don't include a room_id in their /sync data", async () => { - await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); - const p2pSession = await createOlmSession(testOlmAccount, aliceTestClient); - const groupSession = new Olm.OutboundGroupSession(); - groupSession.create(); - - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => "@bob:xyz"; - - // make the room_key event - const roomKeyEncrypted = encryptGroupSessionKey({ - senderKey: testSenderKey, - recipient: aliceTestClient, - p2pSession: p2pSession, - groupSession: groupSession, - room_id: ROOM_ID, - }); - - // encrypt a message with the group session - const messageEncrypted = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - room_id: ROOM_ID, - }); - - const redactionEncrypted = encryptMegolmEvent({ - senderKey: testSenderKey, - groupSession: groupSession, - plaintext: { - room_id: ROOM_ID, - type: "m.room.redaction", - redacts: messageEncrypted.event_id, - content: { reason: "redaction test" }, - }, - }); - - const messageEncryptedWithRedaction = { - ...messageEncrypted, - unsigned: { redacted_because: redactionEncrypted }, - }; - - const syncResponse = { - next_batch: 1, - to_device: { - events: [roomKeyEncrypted], - }, - rooms: { - join: { - [ROOM_ID]: { timeline: { events: [messageEncryptedWithRedaction] } }, - }, - }, - }; - - aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); - await aliceTestClient.flushSync(); - - const room = aliceTestClient.client.getRoom(ROOM_ID)!; - const event = room.getLiveTimeline().getEvents()[0]; - expect(event.isEncrypted()).toBe(true); - await event.attemptDecryption(aliceTestClient.client.crypto!); - expect(event.getContent()).toEqual({}); - const redactionEvent: any = event.getRedactionEvent(); - expect(redactionEvent.content.reason).toEqual("redaction test"); - }); - - it("Alice receives shared history before being invited to a room by the sharer", async () => { + oldBackendOnly("Alice receives shared history before being invited to a room by the sharer", async () => { const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux"); await beccaTestClient.client.initCrypto(); await aliceTestClient.start(); - aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); await beccaTestClient.start(); + // if we're using the old crypto impl, stub out some methods in the device manager. + // TODO: replace this with intercepts of the /keys/query endpoint to make it impl agnostic. + if (aliceTestClient.client.crypto) { + aliceTestClient.client.crypto!.deviceList.downloadKeys = () => Promise.resolve({}); + aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device; + aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!; + } + const beccaRoom = new Room(ROOM_ID, beccaTestClient.client, "@becca:localhost", {}); beccaTestClient.client.store.storeRoom(beccaRoom); await beccaTestClient.client.setRoomEncryption(ROOM_ID, { algorithm: "m.megolm.v1.aes-sha2" }); @@ -1236,8 +1247,6 @@ describe("megolm", () => { event.claimedEd25519Key = null; const device = new DeviceInfo(beccaTestClient.client.deviceId!); - aliceTestClient.client.crypto!.deviceList.getDeviceByIdentityKey = () => device; - aliceTestClient.client.crypto!.deviceList.getUserByIdentityKey = () => beccaTestClient.client.getUserId()!; // Create an olm session for Becca and Alice's devices const aliceOtks = await aliceTestClient.awaitOneTimeKeyUpload(); @@ -1268,6 +1277,7 @@ describe("megolm", () => { ); const encryptedForwardedKey = encryptOlmEvent({ sender: "@becca:localhost", + senderSigningKey: beccaTestClient.getSigningKey(), senderKey: beccaTestClient.getDeviceKey(), recipient: aliceTestClient, p2pSession: p2pSession, @@ -1349,7 +1359,7 @@ describe("megolm", () => { await beccaTestClient.stop(); }); - it("Alice receives shared history before being invited to a room by someone else", async () => { + oldBackendOnly("Alice receives shared history before being invited to a room by someone else", async () => { const beccaTestClient = new TestClient("@becca:localhost", "foobar", "bazquux"); await beccaTestClient.client.initCrypto(); @@ -1413,6 +1423,7 @@ describe("megolm", () => { const encryptedForwardedKey = encryptOlmEvent({ sender: "@becca:localhost", senderKey: beccaTestClient.getDeviceKey(), + senderSigningKey: beccaTestClient.getSigningKey(), recipient: aliceTestClient, p2pSession: p2pSession, plaincontent: { @@ -1494,7 +1505,7 @@ describe("megolm", () => { await beccaTestClient.stop(); }); - it("allows sending an encrypted event as soon as room state arrives", async () => { + oldBackendOnly("allows sending an encrypted event as soon as room state arrives", async () => { /* Empirically, clients expect to be able to send encrypted events as soon as the * RoomStateEvent.NewMember notification is emitted, so test that works correctly. */ @@ -1619,7 +1630,7 @@ describe("megolm", () => { await aliceTestClient.httpBackend.flush(membersPath, 1); } - it("Sending an event initiates a member list sync", async () => { + oldBackendOnly("Sending an event initiates a member list sync", async () => { // we expect a call to the /members list... const memberListPromise = expectMembershipRequest(ROOM_ID, ["@bob:xyz"]); @@ -1651,7 +1662,7 @@ describe("megolm", () => { ]); }); - it("loading the membership list inhibits a later load", async () => { + oldBackendOnly("loading the membership list inhibits a later load", async () => { const room = aliceTestClient.client.getRoom(ROOM_ID)!; await Promise.all([room.loadMembersIfNeeded(), expectMembershipRequest(ROOM_ID, ["@bob:xyz"])]); @@ -1678,4 +1689,79 @@ describe("megolm", () => { await Promise.all([sendPromise, megolmMessagePromise, aliceTestClient.httpBackend.flush("/keys/query", 1)]); }); }); + + describe("m.room_key.withheld handling", () => { + // TODO: there are a bunch more tests for this sort of thing in spec/unit/crypto/algorithms/megolm.spec.ts. + // They should be converted to integ tests and moved. + + oldBackendOnly("does not block decryption on an 'm.unavailable' report", async function () { + await aliceTestClient.start(); + + // there may be a key downloads for alice + aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, {}); + aliceTestClient.httpBackend.flush("/keys/query", 1, 5000); + + // encrypt a message with a group session. + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + const messageEncryptedEvent = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // Alice gets the room message, but not the key + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 1, + rooms: { + join: { [ROOM_ID]: { timeline: { events: [messageEncryptedEvent] } } }, + }, + }); + await aliceTestClient.flushSync(); + + // alice will (eventually) send a room-key request + aliceTestClient.httpBackend.when("PUT", "/sendToDevice/m.room_key_request/").respond(200, {}); + await aliceTestClient.httpBackend.flush("/sendToDevice/m.room_key_request/", 1, 1000); + + // at this point, the message should be a decryption failure + const room = aliceTestClient.client.getRoom(ROOM_ID)!; + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.isDecryptionFailure()).toBeTruthy(); + + // we want to wait for the message to be updated, so create a promise for it + const retryPromise = new Promise((resolve) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + resolve(ev); + }); + }); + + // alice gets back a room-key-withheld notification + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 2, + to_device: { + events: [ + { + type: "m.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: ROOM_ID, + session_id: groupSession.session_id(), + sender_key: testSenderKey, + code: "m.unavailable", + reason: "", + }, + }, + ], + }, + }); + await aliceTestClient.flushSync(); + + // the withheld notification should trigger a retry; wait for it + await retryPromise; + + // finally: the message should still be a regular decryption failure, not a withheld notification. + expect(event.getContent().body).not.toContain("withheld"); + }); + }); }); diff --git a/spec/integ/matrix-client-crypto.spec.ts b/spec/integ/olm-encryption-spec.ts similarity index 99% rename from spec/integ/matrix-client-crypto.spec.ts rename to spec/integ/olm-encryption-spec.ts index 326b8578bc7..ee9c334865c 100644 --- a/spec/integ/matrix-client-crypto.spec.ts +++ b/spec/integ/olm-encryption-spec.ts @@ -22,7 +22,7 @@ limitations under the License. * * Note that megolm (group) conversation is not tested here. * - * See also `megolm.spec.js`. + * See also `crypto.spec.js`. */ // load olm before the sdk if possible diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index a54cf71cb14..98c3879f30b 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -52,10 +52,9 @@ describe("SlidingSyncSdk", () => { const selfAccessToken = "aseukfgwef"; const mockifySlidingSync = (s: SlidingSync): SlidingSync => { - s.getList = jest.fn(); + s.getListParams = jest.fn(); s.getListData = jest.fn(); s.getRoomSubscriptions = jest.fn(); - s.listLength = jest.fn(); s.modifyRoomSubscriptionInfo = jest.fn(); s.modifyRoomSubscriptions = jest.fn(); s.registerExtension = jest.fn(); @@ -115,7 +114,7 @@ describe("SlidingSyncSdk", () => { const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); httpBackend = testClient.httpBackend; client = testClient.client; - mockSlidingSync = mockifySlidingSync(new SlidingSync("", [], {}, client, 0)); + mockSlidingSync = mockifySlidingSync(new SlidingSync("", new Map(), {}, client, 0)); if (testOpts.withCrypto) { httpBackend!.when("GET", "/room_keys/version").respond(404, {}); await client!.initCrypto(); @@ -549,7 +548,7 @@ describe("SlidingSyncSdk", () => { it("emits SyncState.Reconnecting when < FAILED_SYNC_ERROR_THRESHOLD & SyncState.Error when over", async () => { mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { pos: "h", - lists: [], + lists: {}, rooms: {}, extensions: {}, }); @@ -577,7 +576,7 @@ describe("SlidingSyncSdk", () => { it("emits SyncState.Syncing after a previous SyncState.Error", async () => { mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, { pos: "i", - lists: [], + lists: {}, rooms: {}, extensions: {}, }); diff --git a/spec/integ/sliding-sync.spec.ts b/spec/integ/sliding-sync.spec.ts index 368ace4e80e..38094be450a 100644 --- a/spec/integ/sliding-sync.spec.ts +++ b/spec/integ/sliding-sync.spec.ts @@ -64,10 +64,10 @@ describe("SlidingSync", () => { let slidingSync: SlidingSync; it("should start the sync loop upon calling start()", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1); const fakeResp = { pos: "a", - lists: [], + lists: {}, rooms: {}, extensions: {}, }; @@ -90,7 +90,7 @@ describe("SlidingSync", () => { it("should reset the connection on HTTP 400 and send everything again", async () => { // seed the connection with some lists, extensions and subscriptions to verify they are sent again - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1); const roomId = "!sub:localhost"; const subInfo = { timeline_limit: 42, @@ -114,7 +114,7 @@ describe("SlidingSync", () => { }; slidingSync.modifyRoomSubscriptions(new Set([roomId])); slidingSync.modifyRoomSubscriptionInfo(subInfo); - slidingSync.setList(0, listInfo); + slidingSync.setList("a", listInfo); slidingSync.registerExtension(ext); slidingSync.start(); @@ -128,7 +128,7 @@ describe("SlidingSync", () => { expect(body.room_subscriptions).toEqual({ [roomId]: subInfo, }); - expect(body.lists[0]).toEqual(listInfo); + expect(body.lists["a"]).toEqual(listInfo); expect(body.extensions).toBeTruthy(); expect(body.extensions["custom_extension"]).toEqual({ initial: true }); expect(req.queryParams!["pos"]).toBeUndefined(); @@ -137,7 +137,7 @@ describe("SlidingSync", () => { .respond(200, function () { return { pos: "11", - lists: [{ count: 5 }], + lists: { a: { count: 5 } }, extensions: {}, txn_id: txnId, }; @@ -151,7 +151,7 @@ describe("SlidingSync", () => { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual({ + expect(body.lists["a"]).toEqual({ ranges: [[0, 10]], }); expect(body.extensions).toBeTruthy(); @@ -161,7 +161,7 @@ describe("SlidingSync", () => { .respond(200, function () { return { pos: "12", - lists: [{ count: 5 }], + lists: { a: { count: 5 } }, extensions: {}, }; }); @@ -185,7 +185,7 @@ describe("SlidingSync", () => { expect(body.room_subscriptions).toEqual({ [roomId]: subInfo, }); - expect(body.lists[0]).toEqual(listInfo); + expect(body.lists["a"]).toEqual(listInfo); expect(body.extensions).toBeTruthy(); expect(body.extensions["custom_extension"]).toEqual({ initial: true }); expect(req.queryParams!["pos"]).toBeUndefined(); @@ -193,7 +193,7 @@ describe("SlidingSync", () => { .respond(200, function () { return { pos: "1", - lists: [{ count: 6 }], + lists: { a: { count: 6 } }, extensions: {}, }; }); @@ -221,7 +221,7 @@ describe("SlidingSync", () => { it("should be able to subscribe to a room", async () => { // add the subscription - slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1); slidingSync.modifyRoomSubscriptions(new Set([roomId])); httpBackend! .when("POST", syncUrl) @@ -233,7 +233,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "a", - lists: [], + lists: {}, extensions: {}, rooms: { [roomId]: wantRoomData, @@ -266,7 +266,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "a", - lists: [], + lists: {}, extensions: {}, rooms: { [roomId]: wantRoomData, @@ -313,7 +313,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: { [anotherRoomID]: anotherRoomData, @@ -344,7 +344,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, }); const p = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { @@ -402,19 +402,19 @@ describe("SlidingSync", () => { is_dm: true, }, }; - slidingSync = new SlidingSync(proxyBaseUrl, [listReq], {}, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map([["a", listReq]]), {}, client!, 1); httpBackend! .when("POST", syncUrl) .check(function (req) { const body = req.data; logger.log("list", body); expect(body.lists).toBeTruthy(); - expect(body.lists[0]).toEqual(listReq); + expect(body.lists["a"]).toEqual(listReq); }) .respond(200, { pos: "a", - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -424,7 +424,7 @@ describe("SlidingSync", () => { }, ], }, - ], + }, rooms: rooms, }); const listenerData: Record = {}; @@ -444,15 +444,14 @@ describe("SlidingSync", () => { expect(listenerData[roomB]).toEqual(rooms[roomB]); expect(listenerData[roomC]).toEqual(rooms[roomC]); - expect(slidingSync.listLength()).toEqual(1); slidingSync.off(SlidingSyncEvent.RoomData, dataListener); }); it("should be possible to retrieve list data", () => { - expect(slidingSync.getList(0)).toBeDefined(); - expect(slidingSync.getList(5)).toBeNull(); - expect(slidingSync.getListData(5)).toBeNull(); - const syncData = slidingSync.getListData(0)!; + expect(slidingSync.getListParams("a")).toBeDefined(); + expect(slidingSync.getListParams("b")).toBeNull(); + expect(slidingSync.getListData("b")).toBeNull(); + const syncData = slidingSync.getListData("a")!; expect(syncData.joinedCount).toEqual(500); // from previous test expect(syncData.roomIndexToRoomId).toEqual({ 0: roomA, @@ -467,17 +466,17 @@ describe("SlidingSync", () => { .when("POST", syncUrl) .check(function (req) { const body = req.data; - logger.log("next ranges", body.lists[0].ranges); + logger.log("next ranges", body.lists["a"].ranges); expect(body.lists).toBeTruthy(); - expect(body.lists[0]).toEqual({ + expect(body.lists["a"]).toEqual({ // only the ranges should be sent as the rest are unchanged and sticky ranges: newRanges, }); }) .respond(200, { pos: "b", - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -487,15 +486,17 @@ describe("SlidingSync", () => { }, ], }, - ], + }, }); const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.RequestFinished; }); - slidingSync.setListRanges(0, newRanges); + slidingSync.setListRanges("a", newRanges); await httpBackend!.flushAllExpected(); await responseProcessed; + // setListRanges for an invalid list key returns an error + await expect(slidingSync.setListRanges("idontexist", newRanges)).rejects.toBeTruthy(); }); it("should be possible to add an extra list", async () => { @@ -513,19 +514,19 @@ describe("SlidingSync", () => { const body = req.data; logger.log("extra list", body); expect(body.lists).toBeTruthy(); - expect(body.lists[0]).toEqual({ + expect(body.lists["a"]).toEqual({ // only the ranges should be sent as the rest are unchanged and sticky ranges: newRanges, }); - expect(body.lists[1]).toEqual(extraListReq); + expect(body.lists["b"]).toEqual(extraListReq); }) .respond(200, { pos: "c", - lists: [ - { + lists: { + a: { count: 500, }, - { + b: { count: 50, ops: [ { @@ -535,10 +536,10 @@ describe("SlidingSync", () => { }, ], }, - ], + }, }); - listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(1); + listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("b"); expect(joinedCount).toEqual(50); expect(roomIndexToRoomId).toEqual({ 0: roomA, @@ -550,7 +551,7 @@ describe("SlidingSync", () => { const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => { return state === SlidingSyncState.Complete; }); - slidingSync.setList(1, extraListReq); + slidingSync.setList("b", extraListReq); await httpBackend!.flushAllExpected(); await responseProcessed; }); @@ -559,8 +560,8 @@ describe("SlidingSync", () => { // move C (2) to A (0) httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -574,16 +575,16 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); let listPromise = listenUntil( slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(500); expect(roomIndexToRoomId).toEqual({ 0: roomC, @@ -603,8 +604,8 @@ describe("SlidingSync", () => { // move C (0) back to A (2) httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -618,13 +619,13 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); - listPromise = listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(500); expect(roomIndexToRoomId).toEqual({ 0: roomA, @@ -644,8 +645,8 @@ describe("SlidingSync", () => { it("should ignore invalid list indexes", async () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "e", - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -654,16 +655,16 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); const listPromise = listenUntil( slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(500); expect(roomIndexToRoomId).toEqual({ 0: roomA, @@ -684,8 +685,8 @@ describe("SlidingSync", () => { it("should be possible to update a list", async () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", - lists: [ - { + lists: { + a: { count: 42, ops: [ { @@ -699,13 +700,13 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); // update the list with a new filter - slidingSync.setList(0, { + slidingSync.setList("a", { filters: { is_encrypted: true, }, @@ -714,8 +715,8 @@ describe("SlidingSync", () => { const listPromise = listenUntil( slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(42); expect(roomIndexToRoomId).toEqual({ 0: roomB, @@ -738,12 +739,12 @@ describe("SlidingSync", () => { 0: roomB, 1: roomC, }; - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual(indexToRoomId); + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual(indexToRoomId); httpBackend!.when("POST", syncUrl).respond(200, { pos: "f", // currently the list is [B,C] so we will insert D then immediately delete it - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -761,16 +762,16 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); const listPromise = listenUntil( slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(500); expect(roomIndexToRoomId).toEqual(indexToRoomId); return true; @@ -785,14 +786,14 @@ describe("SlidingSync", () => { }); it("should handle deletions correctly", async () => { - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({ 0: roomB, 1: roomC, }); httpBackend!.when("POST", syncUrl).respond(200, { pos: "g", - lists: [ - { + lists: { + a: { count: 499, ops: [ { @@ -801,16 +802,16 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); const listPromise = listenUntil( slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(499); expect(roomIndexToRoomId).toEqual({ 0: roomC, @@ -827,13 +828,13 @@ describe("SlidingSync", () => { }); it("should handle insertions correctly", async () => { - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({ 0: roomC, }); httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", - lists: [ - { + lists: { + a: { count: 500, ops: [ { @@ -843,16 +844,16 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); let listPromise = listenUntil( slidingSync, "SlidingSync.List", - (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(500); expect(roomIndexToRoomId).toEqual({ 0: roomC, @@ -870,8 +871,8 @@ describe("SlidingSync", () => { httpBackend!.when("POST", syncUrl).respond(200, { pos: "h", - lists: [ - { + lists: { + a: { count: 501, ops: [ { @@ -881,13 +882,13 @@ describe("SlidingSync", () => { }, ], }, - { + b: { count: 50, }, - ], + }, }); - listPromise = listenUntil(slidingSync, "SlidingSync.List", (listIndex, joinedCount, roomIndexToRoomId) => { - expect(listIndex).toEqual(0); + listPromise = listenUntil(slidingSync, "SlidingSync.List", (listKey, joinedCount, roomIndexToRoomId) => { + expect(listKey).toEqual("a"); expect(joinedCount).toEqual(501); expect(roomIndexToRoomId).toEqual({ 0: roomC, @@ -910,11 +911,14 @@ describe("SlidingSync", () => { it("should handle insertions with a spurious DELETE correctly", async () => { slidingSync = new SlidingSync( proxyBaseUrl, - [ - { - ranges: [[0, 20]], - }, - ], + new Map([ + [ + "a", + { + ranges: [[0, 20]], + }, + ], + ]), {}, client!, 1, @@ -922,22 +926,22 @@ describe("SlidingSync", () => { // initially start with nothing httpBackend!.when("POST", syncUrl).respond(200, { pos: "a", - lists: [ - { + lists: { + a: { count: 0, ops: [], }, - ], + }, }); slidingSync.start(); await httpBackend!.flushAllExpected(); - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({}); + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({}); // insert a room httpBackend!.when("POST", syncUrl).respond(200, { pos: "b", - lists: [ - { + lists: { + a: { count: 1, ops: [ { @@ -951,18 +955,18 @@ describe("SlidingSync", () => { }, ], }, - ], + }, }); await httpBackend!.flushAllExpected(); - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({ 0: roomA, }); // insert another room httpBackend!.when("POST", syncUrl).respond(200, { pos: "c", - lists: [ - { + lists: { + a: { count: 1, ops: [ { @@ -976,10 +980,10 @@ describe("SlidingSync", () => { }, ], }, - ], + }, }); await httpBackend!.flushAllExpected(); - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({ 0: roomB, 1: roomA, }); @@ -987,8 +991,8 @@ describe("SlidingSync", () => { // insert a final room httpBackend!.when("POST", syncUrl).respond(200, { pos: "c", - lists: [ - { + lists: { + a: { count: 1, ops: [ { @@ -1002,10 +1006,10 @@ describe("SlidingSync", () => { }, ], }, - ], + }, }); await httpBackend!.flushAllExpected(); - expect(slidingSync.getListData(0)!.roomIndexToRoomId).toEqual({ + expect(slidingSync.getListData("a")!.roomIndexToRoomId).toEqual({ 0: roomC, 1: roomB, 2: roomA, @@ -1028,7 +1032,7 @@ describe("SlidingSync", () => { required_state: [["m.room.name", ""]], }; // add the subscription - slidingSync = new SlidingSync(proxyBaseUrl, [], roomSubInfo, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map(), roomSubInfo, client!, 1); // modification before SlidingSync.start() const subscribePromise = slidingSync.modifyRoomSubscriptions(new Set([roomId])); let txnId: string | undefined; @@ -1046,7 +1050,7 @@ describe("SlidingSync", () => { return { pos: "aaa", txn_id: txnId, - lists: [], + lists: {}, extensions: {}, rooms: { [roomId]: { @@ -1065,7 +1069,7 @@ describe("SlidingSync", () => { const newList = { ranges: [[0, 20]], }; - const promise = slidingSync.setList(0, newList); + const promise = slidingSync.setList("a", newList); let txnId: string | undefined; httpBackend! .when("POST", syncUrl) @@ -1073,7 +1077,7 @@ describe("SlidingSync", () => { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual(newList); + expect(body.lists["a"]).toEqual(newList); expect(body.txn_id).toBeTruthy(); txnId = body.txn_id; }) @@ -1081,7 +1085,7 @@ describe("SlidingSync", () => { return { pos: "bbb", txn_id: txnId, - lists: [{ count: 5 }], + lists: { a: { count: 5 } }, extensions: {}, }; }); @@ -1090,7 +1094,7 @@ describe("SlidingSync", () => { expect(txnId).toBeDefined(); }); it("should resolve setListRanges during a connection", async () => { - const promise = slidingSync.setListRanges(0, [[20, 40]]); + const promise = slidingSync.setListRanges("a", [[20, 40]]); let txnId: string | undefined; httpBackend! .when("POST", syncUrl) @@ -1098,7 +1102,7 @@ describe("SlidingSync", () => { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual({ + expect(body.lists["a"]).toEqual({ ranges: [[20, 40]], }); expect(body.txn_id).toBeTruthy(); @@ -1108,7 +1112,7 @@ describe("SlidingSync", () => { return { pos: "ccc", txn_id: txnId, - lists: [{ count: 5 }], + lists: { a: { count: 5 } }, extensions: {}, }; }); @@ -1150,10 +1154,10 @@ describe("SlidingSync", () => { const pushTxn = function (req: MockHttpBackend["requests"][0]) { gotTxnIds.push(req.data.txn_id); }; - const failPromise = slidingSync.setListRanges(0, [[20, 40]]); + const failPromise = slidingSync.setListRanges("a", [[20, 40]]); httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "e" }); // missing txn_id await httpBackend!.flushAllExpected(); - const failPromise2 = slidingSync.setListRanges(0, [[60, 70]]); + const failPromise2 = slidingSync.setListRanges("a", [[60, 70]]); httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "f" }); // missing txn_id await httpBackend!.flushAllExpected(); @@ -1162,7 +1166,7 @@ describe("SlidingSync", () => { expect(failPromise).rejects.toEqual(gotTxnIds[0]); expect(failPromise2).rejects.toEqual(gotTxnIds[1]); - const okPromise = slidingSync.setListRanges(0, [[0, 20]]); + const okPromise = slidingSync.setListRanges("a", [[0, 20]]); let txnId: string | undefined; httpBackend! .when("POST", syncUrl) @@ -1187,10 +1191,10 @@ describe("SlidingSync", () => { const pushTxn = function (req: MockHttpBackend["requests"][0]) { gotTxnIds.push(req.data?.txn_id); }; - const A = slidingSync.setListRanges(0, [[20, 40]]); + const A = slidingSync.setListRanges("a", [[20, 40]]); httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "A" }); await httpBackend!.flushAllExpected(); - const B = slidingSync.setListRanges(0, [[60, 70]]); + const B = slidingSync.setListRanges("a", [[60, 70]]); httpBackend!.when("POST", syncUrl).check(pushTxn).respond(200, { pos: "B" }); // missing txn_id await httpBackend!.flushAllExpected(); @@ -1198,7 +1202,7 @@ describe("SlidingSync", () => { // which is a fail. expect(A).rejects.toEqual(gotTxnIds[0]); - const C = slidingSync.setListRanges(0, [[0, 20]]); + const C = slidingSync.setListRanges("a", [[0, 20]]); let pendingC = true; C.finally(() => { pendingC = false; @@ -1219,7 +1223,7 @@ describe("SlidingSync", () => { expect(pendingC).toBe(true); // C is pending still }); it("should do nothing for unknown txn_ids", async () => { - const promise = slidingSync.setListRanges(0, [[20, 40]]); + const promise = slidingSync.setListRanges("a", [[20, 40]]); let pending = true; promise.finally(() => { pending = false; @@ -1231,7 +1235,7 @@ describe("SlidingSync", () => { const body = req.data; logger.debug("got ", body); expect(body.room_subscriptions).toBeFalsy(); - expect(body.lists[0]).toEqual({ + expect(body.lists["a"]).toEqual({ ranges: [[20, 40]], }); expect(body.txn_id).toBeTruthy(); @@ -1241,7 +1245,7 @@ describe("SlidingSync", () => { return { pos: "ccc", txn_id: "bogus transaction id", - lists: [{ count: 5 }], + lists: { a: { count: 5 } }, extensions: {}, }; }); @@ -1279,7 +1283,7 @@ describe("SlidingSync", () => { }; it("should be possible to use custom subscriptions on startup", async () => { - const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1); + const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1); // the intention is for clients to set this up at startup slidingSync.addCustomSubscription(customSubName1, customSub1); slidingSync.addCustomSubscription(customSubName2, customSub2); @@ -1302,7 +1306,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1312,7 +1316,7 @@ describe("SlidingSync", () => { }); it("should be possible to use custom subscriptions mid-connection", async () => { - const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1); + const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1); // the intention is for clients to set this up at startup slidingSync.addCustomSubscription(customSubName1, customSub1); slidingSync.addCustomSubscription(customSubName2, customSub2); @@ -1326,7 +1330,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1344,7 +1348,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1363,7 +1367,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1383,7 +1387,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1395,7 +1399,7 @@ describe("SlidingSync", () => { }); it("uses the default subscription for unknown subscription names", async () => { - const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1); + const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1); slidingSync.addCustomSubscription(customSubName1, customSub1); slidingSync.useCustomSubscription(roomA, "unknown name"); slidingSync.modifyRoomSubscriptions(new Set([roomA])); @@ -1410,7 +1414,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1420,7 +1424,7 @@ describe("SlidingSync", () => { }); it("should not be possible to add/modify an already added custom subscription", async () => { - const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1); + const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1); slidingSync.addCustomSubscription(customSubName1, customSub1); slidingSync.addCustomSubscription(customSubName1, customSub2); slidingSync.useCustomSubscription(roomA, customSubName1); @@ -1436,7 +1440,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1446,7 +1450,7 @@ describe("SlidingSync", () => { }); it("should change the custom subscription if they are different", async () => { - const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1); + const slidingSync = new SlidingSync(proxyBaseUrl, new Map(), defaultSub, client!, 1); slidingSync.addCustomSubscription(customSubName1, customSub1); slidingSync.addCustomSubscription(customSubName2, customSub2); slidingSync.useCustomSubscription(roomA, customSubName1); @@ -1463,7 +1467,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1484,7 +1488,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1506,7 +1510,7 @@ describe("SlidingSync", () => { }) .respond(200, { pos: "b", - lists: [], + lists: {}, extensions: {}, rooms: {}, }); @@ -1559,7 +1563,7 @@ describe("SlidingSync", () => { }; it("should be able to register an extension", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1); slidingSync.registerExtension(extPre); const callbackOrder: string[] = []; @@ -1684,7 +1688,7 @@ describe("SlidingSync", () => { }); it("is not possible to register the same extension name twice", async () => { - slidingSync = new SlidingSync(proxyBaseUrl, [], {}, client!, 1); + slidingSync = new SlidingSync(proxyBaseUrl, new Map(), {}, client!, 1); slidingSync.registerExtension(extPre); expect(() => { slidingSync.registerExtension(extPre); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 2588a6e1606..55950356472 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -403,3 +403,15 @@ export const mkPusher = (extra: Partial = {}): IPusher => ({ pushkey: "pushpush", ...extra, }); + +/** + * a list of the supported crypto implementations, each with a callback to initialise that implementation + * for the given client + */ +export const CRYPTO_BACKENDS: Record = {}; +export type InitCrypto = (_: MatrixClient) => Promise; + +CRYPTO_BACKENDS["rust-sdk"] = (client: MatrixClient) => client.initRustCrypto(); +if (global.Olm) { + CRYPTO_BACKENDS["libolm"] = (client: MatrixClient) => client.initCrypto(); +} diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index 9a5ddb143c0..2188cbcca2c 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { REFERENCE_RELATION } from "matrix-events-sdk"; - import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location"; import { M_TOPIC } from "../../src/@types/topic"; import { @@ -25,6 +23,7 @@ import { parseBeaconContent, parseTopicContent, } from "../../src/content-helpers"; +import { REFERENCE_RELATION } from "../../src/@types/extensible_events"; describe("Beacon content helpers", () => { describe("makeBeaconInfoContent()", () => { diff --git a/spec/unit/crypto.spec.ts b/spec/unit/crypto.spec.ts index 36a74607840..4bb5b58ca37 100644 --- a/spec/unit/crypto.spec.ts +++ b/spec/unit/crypto.spec.ts @@ -254,6 +254,7 @@ describe("Crypto", function () { sendToDevice: jest.fn(), getKeyBackupVersion: jest.fn(), isGuest: jest.fn(), + emit: jest.fn(), } as unknown as MatrixClient; mockRoomList = {} as unknown as RoomList; diff --git a/spec/unit/crypto/algorithms/megolm.spec.ts b/spec/unit/crypto/algorithms/megolm.spec.ts index 2dca6765d1c..973ec0bd24c 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.ts +++ b/spec/unit/crypto/algorithms/megolm.spec.ts @@ -387,8 +387,10 @@ describe("MegolmDecryption", function () { mockCrypto.baseApis = mockBaseApis; mockRoom = { + roomId: ROOM_ID, getEncryptionTargetMembers: jest.fn().mockReturnValue([{ userId: "@alice:home.server" }]), getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false), + shouldEncryptForInvitedMembers: jest.fn().mockReturnValue(false), } as unknown as Room; }); diff --git a/spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts b/spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts new file mode 100644 index 00000000000..2a0839efa39 --- /dev/null +++ b/spec/unit/extensible_events_v1/ExtensibleEvent.spec.ts @@ -0,0 +1,41 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { ExtensibleEventType, IPartialEvent } from "../../../src/@types/extensible_events"; +import { ExtensibleEvent } from "../../../src/extensible_events_v1/ExtensibleEvent"; + +class MockEvent extends ExtensibleEvent { + public constructor(wireEvent: IPartialEvent) { + super(wireEvent); + } + + public serialize(): IPartialEvent { + throw new Error("Not implemented for tests"); + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + throw new Error("Not implemented for tests"); + } +} + +describe("ExtensibleEvent", () => { + it("should expose the wire event directly", () => { + const input: IPartialEvent = { type: "org.example.custom", content: { hello: "world" } }; + const event = new MockEvent(input); + expect(event.wireFormat).toBe(input); + expect(event.wireContent).toBe(input.content); + }); +}); diff --git a/spec/unit/extensible_events_v1/MessageEvent.spec.ts b/spec/unit/extensible_events_v1/MessageEvent.spec.ts new file mode 100644 index 00000000000..cb41f1de978 --- /dev/null +++ b/spec/unit/extensible_events_v1/MessageEvent.spec.ts @@ -0,0 +1,156 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { + ExtensibleAnyMessageEventContent, + IPartialEvent, + M_HTML, + M_MESSAGE, + M_TEXT, +} from "../../../src/@types/extensible_events"; +import { MessageEvent } from "../../../src/extensible_events_v1/MessageEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; + +describe("MessageEvent", () => { + it("should parse m.text", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_TEXT.name]: "Text here", + }, + }; + const message = new MessageEvent(input); + expect(message.text).toBe("Text here"); + expect(message.html).toBeFalsy(); + expect(message.renderings.length).toBe(1); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + }); + + it("should parse m.html", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_TEXT.name]: "Text here", + [M_HTML.name]: "HTML here", + }, + }; + const message = new MessageEvent(input); + expect(message.text).toBe("Text here"); + expect(message.html).toBe("HTML here"); + expect(message.renderings.length).toBe(2); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + }); + + it("should parse m.message", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_MESSAGE.name]: [ + { body: "Text here", mimetype: "text/plain" }, + { body: "HTML here", mimetype: "text/html" }, + { body: "MD here", mimetype: "text/markdown" }, + ], + + // These should be ignored + [M_TEXT.name]: "WRONG Text here", + [M_HTML.name]: "WRONG HTML here", + }, + }; + const message = new MessageEvent(input); + expect(message.text).toBe("Text here"); + expect(message.html).toBe("HTML here"); + expect(message.renderings.length).toBe(3); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/markdown" && r.body === "MD here")).toBe(true); + }); + + it("should fail to parse missing text", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + hello: "world", + } as any, // force invalid type + }; + expect(() => new MessageEvent(input)).toThrow( + new InvalidEventError("Missing textual representation for event"), + ); + }); + + it("should fail to parse missing plain text in m.message", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_MESSAGE.name]: [{ body: "HTML here", mimetype: "text/html" }], + }, + }; + expect(() => new MessageEvent(input)).toThrow( + new InvalidEventError("m.message is missing a plain text representation"), + ); + }); + + it("should fail to parse non-array m.message", () => { + const input: IPartialEvent = { + type: "org.example.message-like", + content: { + [M_MESSAGE.name]: "invalid", + } as any, // force invalid type + }; + expect(() => new MessageEvent(input)).toThrow(new InvalidEventError("m.message contents must be an array")); + }); + + describe("from & serialize", () => { + it("should serialize to a legacy fallback", () => { + const message = MessageEvent.from("Text here", "HTML here"); + expect(message.text).toBe("Text here"); + expect(message.html).toBe("HTML here"); + expect(message.renderings.length).toBe(2); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + expect(message.renderings.some((r) => r.mimetype === "text/html" && r.body === "HTML here")).toBe(true); + + const serialized = message.serialize(); + expect(serialized.type).toBe("m.room.message"); + expect(serialized.content).toMatchObject({ + [M_MESSAGE.name]: [ + { body: "Text here", mimetype: "text/plain" }, + { body: "HTML here", mimetype: "text/html" }, + ], + body: "Text here", + msgtype: "m.text", + format: "org.matrix.custom.html", + formatted_body: "HTML here", + }); + }); + + it("should serialize non-html content to a legacy fallback", () => { + const message = MessageEvent.from("Text here"); + expect(message.text).toBe("Text here"); + expect(message.renderings.length).toBe(1); + expect(message.renderings.some((r) => r.mimetype === "text/plain" && r.body === "Text here")).toBe(true); + + const serialized = message.serialize(); + expect(serialized.type).toBe("m.room.message"); + expect(serialized.content).toMatchObject({ + [M_TEXT.name]: "Text here", + body: "Text here", + msgtype: "m.text", + format: undefined, + formatted_body: undefined, + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/PollEndEvent.spec.ts b/spec/unit/extensible_events_v1/PollEndEvent.spec.ts new file mode 100644 index 00000000000..349e3fc58f4 --- /dev/null +++ b/spec/unit/extensible_events_v1/PollEndEvent.spec.ts @@ -0,0 +1,107 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { PollEndEventContent, M_POLL_END } from "../../../src/@types/polls"; +import { IPartialEvent, REFERENCE_RELATION, M_TEXT } from "../../../src/@types/extensible_events"; +import { PollEndEvent } from "../../../src/extensible_events_v1/PollEndEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; + +describe("PollEndEvent", () => { + // Note: throughout these tests we don't really bother testing that + // MessageEvent is doing its job. It has its own tests to worry about. + + it("should parse a poll closure", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + }, + }; + const event = new PollEndEvent(input); + expect(event.pollEventId).toBe("$poll"); + expect(event.closingMessage.text).toBe("Poll closed"); + }); + + it("should fail to parse a missing relationship", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + } as any, // force invalid type + }; + expect(() => new PollEndEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse a missing relationship event ID", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + } as any, // force invalid type + }; + expect(() => new PollEndEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse an improper relationship", () => { + const input: IPartialEvent = { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: "org.example.not-relationship", + event_id: "$poll", + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "Poll closed", + } as any, // force invalid type + }; + expect(() => new PollEndEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + describe("from & serialize", () => { + it("should serialize to a poll end event", () => { + const event = PollEndEvent.from("$poll", "Poll closed"); + expect(event.pollEventId).toBe("$poll"); + expect(event.closingMessage.text).toBe("Poll closed"); + + const serialized = event.serialize(); + expect(M_POLL_END.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/PollResponseEvent.spec.ts b/spec/unit/extensible_events_v1/PollResponseEvent.spec.ts new file mode 100644 index 00000000000..49e900407d5 --- /dev/null +++ b/spec/unit/extensible_events_v1/PollResponseEvent.spec.ts @@ -0,0 +1,277 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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_TEXT, IPartialEvent, REFERENCE_RELATION } from "../../../src/@types/extensible_events"; +import { + M_POLL_START, + M_POLL_KIND_DISCLOSED, + PollResponseEventContent, + M_POLL_RESPONSE, +} from "../../../src/@types/polls"; +import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; +import { PollResponseEvent } from "../../../src/extensible_events_v1/PollResponseEvent"; + +const SAMPLE_POLL = new PollStartEvent({ + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "one", [M_TEXT.name]: "ONE" }, + { id: "two", [M_TEXT.name]: "TWO" }, + { id: "thr", [M_TEXT.name]: "THR" }, + ], + }, + }, +}); + +describe("PollResponseEvent", () => { + it("should parse a poll response", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["one"]); + expect(response.pollEventId).toBe("$poll"); + }); + + it("should fail to parse a missing relationship", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + } as any, // force invalid type + }; + expect(() => new PollResponseEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse a missing relationship event ID", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + }, + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + } as any, // force invalid type + }; + expect(() => new PollResponseEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + it("should fail to parse an improper relationship", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: "org.example.not-relationship", + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["one"], + }, + } as any, // force invalid type + }; + expect(() => new PollResponseEvent(input)).toThrow( + new InvalidEventError("Relationship must be a reference to an event"), + ); + }); + + describe("validateAgainst", () => { + it("should spoil the vote when no answers", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: {}, + } as any, // force invalid type + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should spoil the vote when answers are empty", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: [], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should spoil the vote when answers are empty", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: [], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should spoil the vote when answers are not strings", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: [1, 2, 3], + }, + } as any, // force invalid type + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(true); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + describe("consumer usage", () => { + it("should spoil the vote when invalid answers are given", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["A", "B", "C"], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(false); // it won't know better + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(true); + }); + + it("should truncate answers to the poll max selections", () => { + const input: IPartialEvent = { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["one", "two", "thr"], + }, + }, + }; + const response = new PollResponseEvent(input); + expect(response.spoiled).toBe(false); // it won't know better + expect(response.answerIds).toMatchObject(["one", "two", "thr"]); + + response.validateAgainst(SAMPLE_POLL); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["one", "two"]); + }); + }); + }); + + describe("from & serialize", () => { + it("should serialize to a poll response event", () => { + const response = PollResponseEvent.from(["A", "B", "C"], "$poll"); + expect(response.spoiled).toBe(false); + expect(response.answerIds).toMatchObject(["A", "B", "C"]); + expect(response.pollEventId).toBe("$poll"); + + const serialized = response.serialize(); + expect(M_POLL_RESPONSE.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: ["A", "B", "C"], + }, + }); + }); + + it("should serialize a spoiled vote", () => { + const response = PollResponseEvent.from([], "$poll"); + expect(response.spoiled).toBe(true); + expect(response.answerIds).toMatchObject([]); + expect(response.pollEventId).toBe("$poll"); + + const serialized = response.serialize(); + expect(M_POLL_RESPONSE.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: "$poll", + }, + [M_POLL_RESPONSE.name]: { + answers: undefined, + }, + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/PollStartEvent.spec.ts b/spec/unit/extensible_events_v1/PollStartEvent.spec.ts new file mode 100644 index 00000000000..93612069bf4 --- /dev/null +++ b/spec/unit/extensible_events_v1/PollStartEvent.spec.ts @@ -0,0 +1,337 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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_TEXT, IPartialEvent } from "../../../src/@types/extensible_events"; +import { + M_POLL_START, + M_POLL_KIND_DISCLOSED, + PollAnswer, + PollStartEventContent, + M_POLL_KIND_UNDISCLOSED, +} from "../../../src/@types/polls"; +import { PollStartEvent, PollAnswerSubevent } from "../../../src/extensible_events_v1/PollStartEvent"; +import { InvalidEventError } from "../../../src/extensible_events_v1/InvalidEventError"; + +describe("PollAnswerSubevent", () => { + // Note: throughout these tests we don't really bother testing that + // MessageEvent is doing its job. It has its own tests to worry about. + + it("should parse an answer representation", () => { + const input: IPartialEvent = { + type: "org.matrix.sdk.poll.answer", + content: { + id: "one", + [M_TEXT.name]: "ONE", + }, + }; + const answer = new PollAnswerSubevent(input); + expect(answer.id).toBe("one"); + expect(answer.text).toBe("ONE"); + }); + + it("should fail to parse answers without an ID", () => { + const input: IPartialEvent = { + type: "org.matrix.sdk.poll.answer", + content: { + [M_TEXT.name]: "ONE", + } as any, // force invalid type + }; + expect(() => new PollAnswerSubevent(input)).toThrow( + new InvalidEventError("Answer ID must be a non-empty string"), + ); + }); + + it("should fail to parse answers without text", () => { + const input: IPartialEvent = { + type: "org.matrix.sdk.poll.answer", + content: { + id: "one", + } as any, // force invalid type + }; + expect(() => new PollAnswerSubevent(input)).toThrow(); // we don't check message - that'll be MessageEvent's problem + }); + + describe("from & serialize", () => { + it("should serialize to a placeholder representation", () => { + const answer = PollAnswerSubevent.from("one", "ONE"); + expect(answer.id).toBe("one"); + expect(answer.text).toBe("ONE"); + + const serialized = answer.serialize(); + expect(serialized.type).toBe("org.matrix.sdk.poll.answer"); + expect(serialized.content).toMatchObject({ + id: "one", + [M_TEXT.name]: expect.any(String), // tested by MessageEvent + }); + }); + }); +}); + +describe("PollStartEvent", () => { + // Note: throughout these tests we don't really bother testing that + // MessageEvent is doing its job. It has its own tests to worry about. + + it("should parse a poll", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "one", [M_TEXT.name]: "ONE" }, + { id: "two", [M_TEXT.name]: "TWO" }, + { id: "thr", [M_TEXT.name]: "THR" }, + ], + }, + }, + }; + const poll = new PollStartEvent(input); + expect(poll.question).toBeDefined(); + expect(poll.question.text).toBe("Question here"); + expect(poll.kind).toBe(M_POLL_KIND_DISCLOSED); + expect(M_POLL_KIND_DISCLOSED.matches(poll.rawKind)).toBe(true); + expect(poll.maxSelections).toBe(2); + expect(poll.answers.length).toBe(3); + expect(poll.answers.some((a) => a.id === "one" && a.text === "ONE")).toBe(true); + expect(poll.answers.some((a) => a.id === "two" && a.text === "TWO")).toBe(true); + expect(poll.answers.some((a) => a.id === "thr" && a.text === "THR")).toBe(true); + }); + + it("should fail to parse a missing question", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "one", [M_TEXT.name]: "ONE" }, + { id: "two", [M_TEXT.name]: "TWO" }, + { id: "thr", [M_TEXT.name]: "THR" }, + ], + }, + } as any, // force invalid type + }; + expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("A question is required")); + }); + + it("should fail to parse non-array answers", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: "one", + } as any, // force invalid type + }, + }; + expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("Poll answers must be an array")); + }); + + it("should fail to parse invalid answers", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [{ id: "one" }, { [M_TEXT.name]: "TWO" }], + } as any, // force invalid type + }, + }; + expect(() => new PollStartEvent(input)).toThrow(); // error tested by PollAnswerSubevent tests + }); + + it("should fail to parse lack of answers", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [], + } as any, // force invalid type + }, + }; + expect(() => new PollStartEvent(input)).toThrow(new InvalidEventError("No answers available")); + }); + + it("should truncate answers at 20", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + { id: "01", [M_TEXT.name]: "A" }, + { id: "02", [M_TEXT.name]: "B" }, + { id: "03", [M_TEXT.name]: "C" }, + { id: "04", [M_TEXT.name]: "D" }, + { id: "05", [M_TEXT.name]: "E" }, + { id: "06", [M_TEXT.name]: "F" }, + { id: "07", [M_TEXT.name]: "G" }, + { id: "08", [M_TEXT.name]: "H" }, + { id: "09", [M_TEXT.name]: "I" }, + { id: "10", [M_TEXT.name]: "J" }, + { id: "11", [M_TEXT.name]: "K" }, + { id: "12", [M_TEXT.name]: "L" }, + { id: "13", [M_TEXT.name]: "M" }, + { id: "14", [M_TEXT.name]: "N" }, + { id: "15", [M_TEXT.name]: "O" }, + { id: "16", [M_TEXT.name]: "P" }, + { id: "17", [M_TEXT.name]: "Q" }, + { id: "18", [M_TEXT.name]: "R" }, + { id: "19", [M_TEXT.name]: "S" }, + { id: "20", [M_TEXT.name]: "T" }, + { id: "FAIL", [M_TEXT.name]: "U" }, + ], + }, + }, + }; + const poll = new PollStartEvent(input); + expect(poll.answers.length).toBe(20); + expect(poll.answers.some((a) => a.id === "FAIL")).toBe(false); + }); + + it("should infer a kind from unknown kinds", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + kind: "org.example.custom.poll.kind", + max_selections: 2, + answers: [ + { id: "01", [M_TEXT.name]: "A" }, + { id: "02", [M_TEXT.name]: "B" }, + { id: "03", [M_TEXT.name]: "C" }, + ], + }, + }, + }; + const poll = new PollStartEvent(input); + expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED); + expect(poll.rawKind).toBe("org.example.custom.poll.kind"); + }); + + it("should infer a kind from missing kinds", () => { + const input: IPartialEvent = { + type: M_POLL_START.name, + content: { + [M_TEXT.name]: "FALLBACK Question here", + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Question here" }, + max_selections: 2, + answers: [ + { id: "01", [M_TEXT.name]: "A" }, + { id: "02", [M_TEXT.name]: "B" }, + { id: "03", [M_TEXT.name]: "C" }, + ], + } as any, // force invalid type + }, + }; + const poll = new PollStartEvent(input); + expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED); + expect(poll.rawKind).toBeFalsy(); + }); + + describe("from & serialize", () => { + it("should serialize to a poll start event", () => { + const poll = PollStartEvent.from("Question here", ["A", "B", "C"], M_POLL_KIND_DISCLOSED, 2); + expect(poll.question.text).toBe("Question here"); + expect(poll.kind).toBe(M_POLL_KIND_DISCLOSED); + expect(M_POLL_KIND_DISCLOSED.matches(poll.rawKind)).toBe(true); + expect(poll.maxSelections).toBe(2); + expect(poll.answers.length).toBe(3); + expect(poll.answers.some((a) => a.text === "A")).toBe(true); + expect(poll.answers.some((a) => a.text === "B")).toBe(true); + expect(poll.answers.some((a) => a.text === "C")).toBe(true); + + // Ids are non-empty and unique + expect(poll.answers[0].id).toHaveLength(16); + expect(poll.answers[1].id).toHaveLength(16); + expect(poll.answers[2].id).toHaveLength(16); + expect(poll.answers[0].id).not.toEqual(poll.answers[1].id); + expect(poll.answers[0].id).not.toEqual(poll.answers[2].id); + expect(poll.answers[1].id).not.toEqual(poll.answers[2].id); + + const serialized = poll.serialize(); + expect(M_POLL_START.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + [M_TEXT.name]: "Question here\n1. A\n2. B\n3. C", + [M_POLL_START.name]: { + question: { + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests + }, + kind: M_POLL_KIND_DISCLOSED.name, + max_selections: 2, + answers: [ + // M_TEXT tested by MessageEvent tests + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + ], + }, + }); + }); + + it("should serialize to a custom kind poll start event", () => { + const poll = PollStartEvent.from("Question here", ["A", "B", "C"], "org.example.poll.kind", 2); + expect(poll.question.text).toBe("Question here"); + expect(poll.kind).toBe(M_POLL_KIND_UNDISCLOSED); + expect(poll.rawKind).toBe("org.example.poll.kind"); + expect(poll.maxSelections).toBe(2); + expect(poll.answers.length).toBe(3); + expect(poll.answers.some((a) => a.text === "A")).toBe(true); + expect(poll.answers.some((a) => a.text === "B")).toBe(true); + expect(poll.answers.some((a) => a.text === "C")).toBe(true); + + const serialized = poll.serialize(); + expect(M_POLL_START.matches(serialized.type)).toBe(true); + expect(serialized.content).toMatchObject({ + [M_TEXT.name]: "Question here\n1. A\n2. B\n3. C", + [M_POLL_START.name]: { + question: { + [M_TEXT.name]: expect.any(String), // tested by MessageEvent tests + }, + kind: "org.example.poll.kind", + max_selections: 2, + answers: [ + // M_MESSAGE tested by MessageEvent tests + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + { id: expect.any(String), [M_TEXT.name]: expect.any(String) }, + ], + }, + }); + }); + }); +}); diff --git a/spec/unit/extensible_events_v1/utilities.spec.ts b/spec/unit/extensible_events_v1/utilities.spec.ts new file mode 100644 index 00000000000..9fd3636de4e --- /dev/null +++ b/spec/unit/extensible_events_v1/utilities.spec.ts @@ -0,0 +1,87 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { NamespacedValue } from "matrix-events-sdk"; + +import { isEventTypeSame } from "../../../src/@types/extensible_events"; + +describe("isEventTypeSame", () => { + it("should match string and string", () => { + const a = "org.example.message-like"; + const b = "org.example.different"; + + expect(isEventTypeSame(a, b)).toBe(false); + expect(isEventTypeSame(b, a)).toBe(false); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(b, b)).toBe(true); + }); + + it("should match string and namespace", () => { + const a = "org.example.message-like"; + const b = new NamespacedValue("org.example.stable", "org.example.unstable"); + + expect(isEventTypeSame(a, b)).toBe(false); + expect(isEventTypeSame(b, a)).toBe(false); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(b, b)).toBe(true); + expect(isEventTypeSame(b.name, b)).toBe(true); + expect(isEventTypeSame(b.altName, b)).toBe(true); + expect(isEventTypeSame(b, b.name)).toBe(true); + expect(isEventTypeSame(b, b.altName)).toBe(true); + }); + + it("should match namespace and namespace", () => { + const a = new NamespacedValue("org.example.stable1", "org.example.unstable1"); + const b = new NamespacedValue("org.example.stable2", "org.example.unstable2"); + + expect(isEventTypeSame(a, b)).toBe(false); + expect(isEventTypeSame(b, a)).toBe(false); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(a.name, a)).toBe(true); + expect(isEventTypeSame(a.altName, a)).toBe(true); + expect(isEventTypeSame(a, a.name)).toBe(true); + expect(isEventTypeSame(a, a.altName)).toBe(true); + + expect(isEventTypeSame(b, b)).toBe(true); + expect(isEventTypeSame(b.name, b)).toBe(true); + expect(isEventTypeSame(b.altName, b)).toBe(true); + expect(isEventTypeSame(b, b.name)).toBe(true); + expect(isEventTypeSame(b, b.altName)).toBe(true); + }); + + it("should match namespaces of different pointers", () => { + const a = new NamespacedValue("org.example.stable", "org.example.unstable"); + const b = new NamespacedValue("org.example.stable", "org.example.unstable"); + + expect(isEventTypeSame(a, b)).toBe(true); + expect(isEventTypeSame(b, a)).toBe(true); + + expect(isEventTypeSame(a, a)).toBe(true); + expect(isEventTypeSame(a.name, a)).toBe(true); + expect(isEventTypeSame(a.altName, a)).toBe(true); + expect(isEventTypeSame(a, a.name)).toBe(true); + expect(isEventTypeSame(a, a.altName)).toBe(true); + + expect(isEventTypeSame(b, b)).toBe(true); + expect(isEventTypeSame(b.name, b)).toBe(true); + expect(isEventTypeSame(b.altName, b)).toBe(true); + expect(isEventTypeSame(b, b.name)).toBe(true); + expect(isEventTypeSame(b, b.altName)).toBe(true); + }); +}); diff --git a/spec/unit/location.spec.ts b/spec/unit/location.spec.ts index 953d3b611b1..ff24d663801 100644 --- a/spec/unit/location.spec.ts +++ b/spec/unit/location.spec.ts @@ -22,7 +22,7 @@ import { M_TIMESTAMP, LocationEventWireContent, } from "../../src/@types/location"; -import { TEXT_NODE_TYPE } from "../../src/@types/extensible_events"; +import { M_TEXT } from "../../src/@types/extensible_events"; import { MsgType } from "../../src/@types/event"; describe("Location", function () { @@ -32,7 +32,7 @@ describe("Location", function () { geo_uri: "geo:-36.24484561954707,175.46884959563613;u=10", [M_LOCATION.name]: { uri: "geo:-36.24484561954707,175.46884959563613;u=10", description: null }, [M_ASSET.name]: { type: "m.self" }, - [TEXT_NODE_TYPE.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", + [M_TEXT.name]: "Location geo:-36.24484561954707,175.46884959563613;u=10 at 2022-03-09T11:01:52.443Z", [M_TIMESTAMP.name]: 1646823712443, } as any; @@ -59,7 +59,7 @@ describe("Location", function () { description: undefined, }); expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Self }); - expect(TEXT_NODE_TYPE.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z"); + expect(M_TEXT.findIn(loc)).toEqual("User Location geo:foo at 1970-01-02T13:17:15.435Z"); expect(M_TIMESTAMP.findIn(loc)).toEqual(134235435); }); @@ -74,7 +74,7 @@ describe("Location", function () { description: "desc", }); expect(M_ASSET.findIn(loc)).toEqual({ type: LocationAssetType.Pin }); - expect(TEXT_NODE_TYPE.findIn(loc)).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z'); + expect(M_TEXT.findIn(loc)).toEqual('Location "desc" geo:bar at 1970-01-02T13:17:15.436Z'); expect(M_TIMESTAMP.findIn(loc)).toEqual(134235436); }); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index b692e10ce22..edfd6d7e9c4 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -2198,7 +2198,7 @@ describe("MatrixClient", function () { }); }); - describe("getVisibleRooms", () => { + describe("room lists and history", () => { function roomCreateEvent(newRoomId: string, predecessorRoomId: string): MatrixEvent { return new MatrixEvent({ content: { @@ -2234,45 +2234,127 @@ describe("MatrixClient", function () { }); } - it("Returns an empty list if there are no rooms", () => { - client.store = new StubStore(); - client.store.getRooms = () => []; - const rooms = client.getVisibleRooms(); - expect(rooms).toHaveLength(0); - }); - - it("Returns all non-replaced rooms", () => { - const room1 = new Room("room1", client, "@carol:alexandria.example.com"); - const room2 = new Room("room2", client, "@daryl:alexandria.example.com"); - client.store = new StubStore(); - client.store.getRooms = () => [room1, room2]; - const rooms = client.getVisibleRooms(); - expect(rooms).toContain(room1); - expect(rooms).toContain(room2); - expect(rooms).toHaveLength(2); - }); - - it("Does not return replaced rooms", () => { - // Given 4 rooms, 2 of which have been replaced - const room1 = new Room("room1", client, "@carol:alexandria.example.com"); - const replacedRoom1 = new Room("replacedRoom1", client, "@carol:alexandria.example.com"); - const replacedRoom2 = new Room("replacedRoom2", client, "@carol:alexandria.example.com"); - const room2 = new Room("room2", client, "@daryl:alexandria.example.com"); - client.store = new StubStore(); - client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2]; - room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], {}); - room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], {}); - replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], {}); - replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], {}); - - // When we ask for the visible rooms - const rooms = client.getVisibleRooms(); - - // Then we only get the ones that have not been replaced - expect(rooms).not.toContain(replacedRoom1); - expect(rooms).not.toContain(replacedRoom2); - expect(rooms).toContain(room1); - expect(rooms).toContain(room2); + describe("getVisibleRooms", () => { + it("Returns an empty list if there are no rooms", () => { + client.store = new StubStore(); + client.store.getRooms = () => []; + const rooms = client.getVisibleRooms(); + expect(rooms).toHaveLength(0); + }); + + it("Returns all non-replaced rooms", () => { + const room1 = new Room("room1", client, "@carol:alexandria.example.com"); + const room2 = new Room("room2", client, "@daryl:alexandria.example.com"); + client.store = new StubStore(); + client.store.getRooms = () => [room1, room2]; + const rooms = client.getVisibleRooms(); + expect(rooms).toContain(room1); + expect(rooms).toContain(room2); + expect(rooms).toHaveLength(2); + }); + + it("Does not return replaced rooms", () => { + // Given 4 rooms, 2 of which have been replaced + const room1 = new Room("room1", client, "@carol:alexandria.example.com"); + const replacedRoom1 = new Room("replacedRoom1", client, "@carol:alexandria.example.com"); + const replacedRoom2 = new Room("replacedRoom2", client, "@carol:alexandria.example.com"); + const room2 = new Room("room2", client, "@daryl:alexandria.example.com"); + client.store = new StubStore(); + client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2]; + room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], {}); + room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], {}); + replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], {}); + replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], {}); + + // When we ask for the visible rooms + const rooms = client.getVisibleRooms(); + + // Then we only get the ones that have not been replaced + expect(rooms).not.toContain(replacedRoom1); + expect(rooms).not.toContain(replacedRoom2); + expect(rooms).toContain(room1); + expect(rooms).toContain(room2); + }); + }); + + describe("getRoomUpgradeHistory", () => { + function createRoomHistory(): [Room, Room, Room, Room] { + const room1 = new Room("room1", client, "@carol:alexandria.example.com"); + const room2 = new Room("room2", client, "@daryl:alexandria.example.com"); + const room3 = new Room("room3", client, "@rick:helicopter.example.com"); + const room4 = new Room("room4", client, "@michonne:hawthorne.example.com"); + + room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], {}); + room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)]); + + room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], {}); + room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)]); + + room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.roomId)], {}); + room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)]); + + mocked(store.getRoom).mockImplementation((roomId: string) => { + switch (roomId) { + case "room1": + return room1; + case "room2": + return room2; + case "room3": + return room3; + case "room4": + return room4; + default: + return null; + } + }); + + return [room1, room2, room3, room4]; + } + + it("Returns an empty list if room does not exist", () => { + const history = client.getRoomUpgradeHistory("roomthatdoesnotexist"); + expect(history).toHaveLength(0); + }); + + it("Returns just this room if there is no predecessor", () => { + const mainRoom = new Room("mainRoom", client, "@carol:alexandria.example.com"); + mocked(store.getRoom).mockReturnValue(mainRoom); + const history = client.getRoomUpgradeHistory(mainRoom.roomId); + expect(history).toEqual([mainRoom]); + }); + + it("Returns the predecessors of this room", () => { + const [room1, room2, room3, room4] = createRoomHistory(); + const history = client.getRoomUpgradeHistory(room4.roomId); + expect(history.map((room) => room.roomId)).toEqual([ + room1.roomId, + room2.roomId, + room3.roomId, + room4.roomId, + ]); + }); + + it("Returns the subsequent rooms", () => { + const [room1, room2, room3, room4] = createRoomHistory(); + const history = client.getRoomUpgradeHistory(room1.roomId); + expect(history.map((room) => room.roomId)).toEqual([ + room1.roomId, + room2.roomId, + room3.roomId, + room4.roomId, + ]); + }); + + it("Returns the predecessors and subsequent rooms", () => { + const [room1, room2, room3, room4] = createRoomHistory(); + const history = client.getRoomUpgradeHistory(room3.roomId); + expect(history.map((room) => room.roomId)).toEqual([ + room1.roomId, + room2.roomId, + room3.roomId, + room4.roomId, + ]); + }); }); }); }); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index 620e4a8fda1..b3042cd6a31 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { REFERENCE_RELATION } from "matrix-events-sdk"; - +import { REFERENCE_RELATION } from "../../../src/@types/extensible_events"; import { MatrixEvent } from "../../../src"; import { M_BEACON_INFO } from "../../../src/@types/beacon"; import { isTimestampInDuration, Beacon, BeaconEvent } from "../../../src/models/beacon"; diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index cf4997c2809..98525994008 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { M_POLL_START } from "matrix-events-sdk"; - +import { M_POLL_START } from "../../src/@types/polls"; import { EventTimelineSet } from "../../src/models/event-timeline-set"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { Room } from "../../src/models/room"; diff --git a/spec/unit/rust-crypto.spec.ts b/spec/unit/rust-crypto.spec.ts index 69dde71239e..50e2c856caa 100644 --- a/spec/unit/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto.spec.ts @@ -32,6 +32,9 @@ import { RustCrypto } from "../../src/rust-crypto/rust-crypto"; import { initRustCrypto } from "../../src/rust-crypto"; import { HttpApiEvent, HttpApiEventHandlerMap, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../src"; import { TypedEventEmitter } from "../../src/models/typed-event-emitter"; +import { mkEvent } from "../test-utils/test-utils"; +import { CryptoBackend } from "../../src/common-crypto/CryptoBackend"; +import { IEventDecryptionResult } from "../../src/@types/crypto"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -245,4 +248,33 @@ describe("RustCrypto", () => { expect(outgoingRequestQueue.length).toEqual(2); }); }); + + describe(".getEventEncryptionInfo", () => { + let rustCrypto: RustCrypto; + + beforeEach(async () => { + const mockHttpApi = {} as MatrixClient["http"]; + rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto; + }); + + it("should handle unencrypted events", () => { + const event = mkEvent({ event: true, type: "m.room.message", content: { body: "xyz" } }); + const res = rustCrypto.getEventEncryptionInfo(event); + expect(res.encrypted).toBeFalsy(); + }); + + it("should handle encrypted events", async () => { + const event = mkEvent({ event: true, type: "m.room.encrypted", content: { algorithm: "fake_alg" } }); + const mockCryptoBackend = { + decryptEvent: () => + ({ + senderCurve25519Key: "1234", + } as IEventDecryptionResult), + } as unknown as CryptoBackend; + await event.attemptDecryption(mockCryptoBackend); + + const res = rustCrypto.getEventEncryptionInfo(event); + expect(res.encrypted).toBeTruthy(); + }); + }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 9ddf880aba9..ce966fbb79f 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -1385,7 +1385,7 @@ describe("Call", function () { }), ); // @ts-ignore Mock - expect(call.terminate).toHaveBeenCalledWith(CallParty.Local, CallErrorCode.Transfered, true); + expect(call.terminate).toHaveBeenCalledWith(CallParty.Local, CallErrorCode.Transferred, true); }); }); diff --git a/src/@types/beacon.ts b/src/@types/beacon.ts index 4f2b257a767..e6bfb8ff95a 100644 --- a/src/@types/beacon.ts +++ b/src/@types/beacon.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk"; - +import { RelatesToRelationship, REFERENCE_RELATION } from "./extensible_events"; import { UnstableValue } from "../NamespacedValue"; import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; @@ -138,4 +137,4 @@ export type MBeaconEventContent = MLocationEvent & // timestamp when location was taken MTimestampEvent & // relates to a beacon_info event - RELATES_TO_RELATIONSHIP; + RelatesToRelationship; diff --git a/src/@types/extensible_events.ts b/src/@types/extensible_events.ts index 51e9d3c3c1f..db9ea1806bd 100644 --- a/src/@types/extensible_events.ts +++ b/src/@types/extensible_events.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -14,8 +14,138 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Types for MSC1767: Extensible events in Matrix +import { EitherAnd, NamespacedValue, Optional, UnstableValue } from "matrix-events-sdk"; -import { UnstableValue } from "../NamespacedValue"; +import { isProvided } from "../extensible_events_v1/utilities"; -export const TEXT_NODE_TYPE = new UnstableValue("m.text", "org.matrix.msc1767.text"); +// Types and utilities for MSC1767: Extensible events (version 1) in Matrix + +/** + * Represents the stable and unstable values of a given namespace. + */ +export type TSNamespace = N extends NamespacedValue + ? TSNamespaceValue | TSNamespaceValue + : never; + +/** + * Represents a namespaced value, if the value is a string. Used to extract provided types + * from a TSNamespace (in cases where only stable *or* unstable is provided). + */ +export type TSNamespaceValue = V extends string ? V : never; + +/** + * Creates a type which is V when T is `never`, otherwise T. + */ +// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for details on the array syntax. +export type DefaultNever = [T] extends [never] ? V : T; + +/** + * The namespaced value for m.message + */ +export const M_MESSAGE = new UnstableValue("m.message", "org.matrix.msc1767.message"); + +/** + * An m.message event rendering + */ +export interface IMessageRendering { + body: string; + mimetype?: string; +} + +/** + * The content for an m.message event + */ +export type ExtensibleMessageEventContent = EitherAnd< + { [M_MESSAGE.name]: IMessageRendering[] }, + { [M_MESSAGE.altName]: IMessageRendering[] } +>; + +/** + * The namespaced value for m.text + */ +export const M_TEXT = new UnstableValue("m.text", "org.matrix.msc1767.text"); + +/** + * The content for an m.text event + */ +export type TextEventContent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>; + +/** + * The namespaced value for m.html + */ +export const M_HTML = new UnstableValue("m.html", "org.matrix.msc1767.html"); + +/** + * The content for an m.html event + */ +export type HtmlEventContent = EitherAnd<{ [M_HTML.name]: string }, { [M_HTML.altName]: string }>; + +/** + * The content for an m.message, m.text, or m.html event + */ +export type ExtensibleAnyMessageEventContent = ExtensibleMessageEventContent | TextEventContent | HtmlEventContent; + +/** + * The namespaced value for an m.reference relation + */ +export const REFERENCE_RELATION = new NamespacedValue("m.reference"); + +/** + * Represents any relation type + */ +export type AnyRelation = TSNamespace | string; + +/** + * An m.relates_to relationship + */ +export type RelatesToRelationship = { + "m.relates_to": { + // See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax + rel_type: [R] extends [never] ? AnyRelation : TSNamespace; + event_id: string; + } & DefaultNever; +}; + +/** + * Partial types for a Matrix Event. + */ +export interface IPartialEvent { + type: string; + content: TContent; +} + +/** + * Represents a potentially namespaced event type. + */ +export type ExtensibleEventType = NamespacedValue | string; + +/** + * Determines if two event types are the same, including namespaces. + * @param given - The given event type. This will be compared + * against the expected type. + * @param expected - The expected event type. + * @returns True if the given type matches the expected type. + */ +export function isEventTypeSame( + given: Optional, + expected: Optional, +): boolean { + if (typeof given === "string") { + if (typeof expected === "string") { + return expected === given; + } else { + return (expected as NamespacedValue).matches(given as string); + } + } else { + if (typeof expected === "string") { + return (given as NamespacedValue).matches(expected as string); + } else { + const expectedNs = expected as NamespacedValue; + const givenNs = given as NamespacedValue; + return ( + expectedNs.matches(givenNs.name) || + (isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName!)) + ); + } + } +} diff --git a/src/@types/location.ts b/src/@types/location.ts index 023557b7788..d1a826fd8f1 100644 --- a/src/@types/location.ts +++ b/src/@types/location.ts @@ -18,7 +18,7 @@ limitations under the License. import { EitherAnd } from "matrix-events-sdk"; import { UnstableValue } from "../NamespacedValue"; -import { TEXT_NODE_TYPE } from "./extensible_events"; +import { M_TEXT } from "./extensible_events"; export enum LocationAssetType { Self = "m.self", @@ -50,7 +50,7 @@ export type MLocationEvent = EitherAnd< { [M_LOCATION.altName]: MLocationContent } >; -export type MTextEvent = EitherAnd<{ [TEXT_NODE_TYPE.name]: string }, { [TEXT_NODE_TYPE.altName]: string }>; +export type MTextEvent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>; /* From the spec at: * https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md diff --git a/src/@types/polls.ts b/src/@types/polls.ts new file mode 100644 index 00000000000..3b06f932a62 --- /dev/null +++ b/src/@types/polls.ts @@ -0,0 +1,119 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EitherAnd, UnstableValue } from "matrix-events-sdk"; + +import { + ExtensibleAnyMessageEventContent, + REFERENCE_RELATION, + RelatesToRelationship, + TSNamespace, +} from "./extensible_events"; + +/** + * Identifier for a disclosed poll. + */ +export const M_POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed"); + +/** + * Identifier for an undisclosed poll. + */ +export const M_POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed"); + +/** + * Any poll kind. + */ +export type PollKind = TSNamespace | TSNamespace | string; + +/** + * Known poll kind namespaces. + */ +export type KnownPollKind = typeof M_POLL_KIND_DISCLOSED | typeof M_POLL_KIND_UNDISCLOSED; + +/** + * The namespaced value for m.poll.start + */ +export const M_POLL_START = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start"); + +/** + * The m.poll.start type within event content + */ +export type PollStartSubtype = { + question: ExtensibleAnyMessageEventContent; + kind: PollKind; + max_selections?: number; // default 1, always positive + answers: PollAnswer[]; +}; + +/** + * A poll answer. + */ +export type PollAnswer = ExtensibleAnyMessageEventContent & { id: string }; + +/** + * The event definition for an m.poll.start event (in content) + */ +export type PollStartEvent = EitherAnd< + { [M_POLL_START.name]: PollStartSubtype }, + { [M_POLL_START.altName]: PollStartSubtype } +>; + +/** + * The content for an m.poll.start event + */ +export type PollStartEventContent = PollStartEvent & ExtensibleAnyMessageEventContent; + +/** + * The namespaced value for m.poll.response + */ +export const M_POLL_RESPONSE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response"); + +/** + * The m.poll.response type within event content + */ +export type PollResponseSubtype = { + answers: string[]; +}; + +/** + * The event definition for an m.poll.response event (in content) + */ +export type PollResponseEvent = EitherAnd< + { [M_POLL_RESPONSE.name]: PollResponseSubtype }, + { [M_POLL_RESPONSE.altName]: PollResponseSubtype } +>; + +/** + * The content for an m.poll.response event + */ +export type PollResponseEventContent = PollResponseEvent & RelatesToRelationship; + +/** + * The namespaced value for m.poll.end + */ +export const M_POLL_END = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end"); + +/** + * The event definition for an m.poll.end event (in content) + */ +export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>; + +/** + * The content for an m.poll.end event + */ +export type PollEndEventContent = PollEndEvent & + RelatesToRelationship & + ExtensibleAnyMessageEventContent; diff --git a/src/@types/topic.ts b/src/@types/topic.ts index 5b66e07c46f..04d14640680 100644 --- a/src/@types/topic.ts +++ b/src/@types/topic.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EitherAnd, IMessageRendering } from "matrix-events-sdk"; +import { EitherAnd } from "matrix-events-sdk"; import { UnstableValue } from "../NamespacedValue"; +import { IMessageRendering } from "./extensible_events"; /** * Extensible topic event type based on MSC3765 diff --git a/src/client.ts b/src/client.ts index 91cc21be0b0..2a579777117 100644 --- a/src/client.ts +++ b/src/client.ts @@ -855,6 +855,7 @@ export enum ClientEvent { SyncUnexpectedError = "sync.unexpectedError", ClientWellKnown = "WellKnown.client", ReceivedVoipEvent = "received_voip_event", + UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable", TurnServers = "turnServers", TurnServersError = "turnServers.error", } @@ -1063,6 +1064,18 @@ export type ClientEventHandlerMap = { * ``` */ [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void; + /** + * Fires if a to-device event is received that cannot be decrypted. + * Encrypted to-device events will (generally) use plain Olm encryption, + * in which case decryption failures are fatal: the event will never be + * decryptable, unlike Megolm encrypted events where the key may simply + * arrive later. + * + * An undecryptable to-device event is therefore likley to indicate problems. + * + * @param event - The undecyptable to-device event + */ + [ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void; /** * Fires whenever new user-scoped account_data is added. * @param event - The event describing the account_data just added @@ -2494,10 +2507,10 @@ export class MatrixClient extends TypedEventEmitter; + /** + * Get the verification level for a given user + * + * TODO: define this better + * + * @param userId - user to be checked + */ + checkUserTrust(userId: string): UserTrustLevel; + + /** + * Get the verification level for a given device + * + * TODO: define this better + * + * @param userId - user to be checked + * @param deviceId - device to be checked + */ + checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel; + /** * Decrypt a received event * @@ -62,6 +83,13 @@ export interface CryptoBackend extends SyncCryptoCallbacks { */ decryptEvent(event: MatrixEvent): Promise; + /** + * Get information about the encryption of an event + * + * @param event - event to be checked + */ + getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo; + /** * Get a list containing all of the room keys * diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 03bacbb9f4a..88bcd90ef62 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk"; - import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; import { MsgType } from "./@types/event"; -import { TEXT_NODE_TYPE } from "./@types/extensible_events"; +import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events"; +import { isProvided } from "./extensible_events_v1/utilities"; import { M_ASSET, LocationAssetType, @@ -160,7 +159,7 @@ export const makeLocationContent = ( [M_ASSET.name]: { type: assetType || LocationAssetType.Self, }, - [TEXT_NODE_TYPE.name]: defaultedText, + [M_TEXT.name]: defaultedText, ...timestampEvent, } as LegacyLocationEventContent & MLocationEventContent; }; @@ -173,7 +172,7 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent): const location = M_LOCATION.findIn(wireEventContent); const asset = M_ASSET.findIn(wireEventContent); const timestamp = M_TIMESTAMP.findIn(wireEventContent); - const text = TEXT_NODE_TYPE.findIn(wireEventContent); + const text = M_TEXT.findIn(wireEventContent); const geoUri = location?.uri ?? wireEventContent?.geo_uri; const description = location?.description; diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts index 1ade989887e..82a0a9a4692 100644 --- a/src/crypto/OlmDevice.ts +++ b/src/crypto/OlmDevice.ts @@ -368,6 +368,11 @@ export class OlmDevice { */ private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void { const sessionId = sessionInfo.session.session_id(); + logger.debug(`Saving Olm session ${sessionId} with device ${deviceKey}: ${sessionInfo.session.describe()}`); + + // Why do we re-use the input object for this, overwriting the same key with a different + // type? Is it because we want to erase the unpickled session to enforce that it's no longer + // used? A comment would be great. const pickledSessionInfo = Object.assign(sessionInfo, { session: sessionInfo.session.pickle(this.pickleKey), }); diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index af9588f91d0..163d3953d45 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -21,7 +21,7 @@ limitations under the License. import { v4 as uuidv4 } from "uuid"; import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto"; -import { logger } from "../../logger"; +import { logger, PrefixedLogger } from "../../logger"; import * as olmlib from "../olmlib"; import { DecryptionAlgorithm, @@ -226,10 +226,12 @@ export class MegolmEncryption extends EncryptionAlgorithm { }; protected readonly roomId: string; + private readonly prefixedLogger: PrefixedLogger; public constructor(params: IParams & Required>) { super(params); this.roomId = params.roomId; + this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`); this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; @@ -291,7 +293,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { // setupPromise resolves to `null` or the `OutboundSessionInfo` whether // or not the share succeeds this.setupPromise = fallible.catch((e) => { - logger.error(`Failed to setup outbound session in ${this.roomId}`, e); + this.prefixedLogger.error(`Failed to setup outbound session`, e); return null; }); @@ -311,7 +313,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { // need to make a brand new session? if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { - logger.log("Starting new megolm session because we need to rotate."); + this.prefixedLogger.log("Starting new megolm session because we need to rotate."); session = null; } @@ -321,9 +323,9 @@ export class MegolmEncryption extends EncryptionAlgorithm { } if (!session) { - logger.log(`Starting new megolm session for room ${this.roomId}`); + this.prefixedLogger.log("Starting new megolm session"); session = await this.prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this.roomId}`); + this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`); this.outboundSessions[session.sessionId] = session; } @@ -376,14 +378,24 @@ export class MegolmEncryption extends EncryptionAlgorithm { await Promise.all([ (async (): Promise => { // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions); + const olmSessionList = Object.entries(olmSessions) + .map(([userId, sessionsByUser]) => + Object.entries(sessionsByUser).map( + ([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`, + ), + ) + .flat(1); + this.prefixedLogger.debug("Sharing keys with devices with existing Olm sessions:", olmSessionList); await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); - logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + this.prefixedLogger.debug("Shared keys with existing Olm sessions"); })(), (async (): Promise => { - logger.debug( - `Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`, - devicesWithoutSession, + const deviceList = Object.entries(devicesWithoutSession) + .map(([userId, devicesByUser]) => devicesByUser.map((device) => `${userId}/${device.deviceId}`)) + .flat(1); + this.prefixedLogger.debug( + "Sharing keys (start phase 1) with devices without existing Olm sessions:", + deviceList, ); const errorDevices: IOlmDevice[] = []; @@ -403,7 +415,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { singleOlmCreationPhase ? 10000 : 2000, failedServers, ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + this.prefixedLogger.debug("Shared keys (end phase 1) with devices without existing Olm sessions"); if (!singleOlmCreationPhase && Date.now() - start < 10000) { // perform the second phase of olm session creation if requested, @@ -432,25 +444,40 @@ export class MegolmEncryption extends EncryptionAlgorithm { } } - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); - await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + const retryDeviceList = Object.entries(retryDevices) + .map(([userId, devicesByUser]) => + devicesByUser.map((device) => `${userId}/${device.deviceId}`), + ) + .flat(1); + + if (retryDeviceList.length > 0) { + this.prefixedLogger.debug( + "Sharing keys (start phase 2) with devices without existing Olm sessions:", + retryDeviceList, + ); + await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); + this.prefixedLogger.debug( + "Shared keys (end phase 2) with devices without existing Olm sessions", + ); + } await this.notifyFailedOlmDevices(session, key, failedDevices); })(); } else { await this.notifyFailedOlmDevices(session, key, errorDevices); } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); })(), (async (): Promise => { - logger.debug( - `There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, - Object.entries(blocked), + this.prefixedLogger.debug( + `There are ${Object.entries(blocked).length} blocked devices:`, + Object.entries(blocked) + .map(([userId, blockedByUser]) => + Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`), + ) + .flat(1), ); // also, notify newly blocked devices that they're blocked - logger.debug(`Notifying newly blocked devices in ${this.roomId}`); const blockedMap: Record> = {}; let blockedCount = 0; for (const [userId, userBlockedDevices] of Object.entries(blocked)) { @@ -466,8 +493,18 @@ export class MegolmEncryption extends EncryptionAlgorithm { } } - await this.notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap); + if (blockedCount) { + this.prefixedLogger.debug( + `Notifying ${blockedCount} newly blocked devices:`, + Object.entries(blockedMap) + .map(([userId, blockedByUser]) => + Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`), + ) + .flat(1), + ); + await this.notifyBlockedDevices(session, blockedMap); + this.prefixedLogger.debug(`Notified ${blockedCount} newly blocked devices`); + } })(), ]); } @@ -620,7 +657,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { } }) .catch((error) => { - logger.error("failed to encryptAndSendToDevices", error); + this.prefixedLogger.error("failed to encryptAndSendToDevices", error); throw error; }); } @@ -694,25 +731,25 @@ export class MegolmEncryption extends EncryptionAlgorithm { ): Promise { const obSessionInfo = this.outboundSessions[sessionId]; if (!obSessionInfo) { - logger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`); + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`); return; } // The chain index of the key we previously sent this device if (obSessionInfo.sharedWithDevices[userId] === undefined) { - logger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`); + this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`); return; } const sessionSharedData = obSessionInfo.sharedWithDevices[userId][device.deviceId]; if (sessionSharedData === undefined) { - logger.debug( + this.prefixedLogger.debug( `megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`, ); return; } if (sessionSharedData.deviceKey !== device.getIdentityKey()) { - logger.warn( + this.prefixedLogger.warn( `Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` + `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`, ); @@ -729,7 +766,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { ); if (!key) { - logger.warn( + this.prefixedLogger.warn( `No inbound session key found for megolm session ${senderKey}|${sessionId}: not re-sharing keys`, ); return; @@ -775,7 +812,9 @@ export class MegolmEncryption extends EncryptionAlgorithm { [device.deviceId]: encryptedContent, }, }); - logger.debug(`Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`); + this.prefixedLogger.debug( + `Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`, + ); } /** @@ -807,7 +846,6 @@ export class MegolmEncryption extends EncryptionAlgorithm { otkTimeout: number, failedServers?: string[], ): Promise { - logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( this.olmDevice, this.baseApis, @@ -815,15 +853,10 @@ export class MegolmEncryption extends EncryptionAlgorithm { false, otkTimeout, failedServers, - logger.withPrefix?.(`[${this.roomId}]`), + this.prefixedLogger, ); - logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); - this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); - - logger.debug(`Sharing keys with newly created Olm sessions in ${this.roomId}`); await this.shareKeyWithOlmSessions(session, key, payload, devicemap); - logger.debug(`Shared keys with newly created Olm sessions in ${this.roomId}`); } private async shareKeyWithOlmSessions( @@ -835,17 +868,16 @@ export class MegolmEncryption extends EncryptionAlgorithm { const userDeviceMaps = this.splitDevices(devicemap); for (let i = 0; i < userDeviceMaps.length; i++) { - const taskDetail = - `megolm keys for ${session.sessionId} ` + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; + const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`; try { - logger.debug( + this.prefixedLogger.debug( `Sharing ${taskDetail}`, userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`), ); await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload); - logger.debug(`Shared ${taskDetail}`); + this.prefixedLogger.debug(`Shared ${taskDetail}`); } catch (e) { - logger.error(`Failed to share ${taskDetail}`); + this.prefixedLogger.error(`Failed to share ${taskDetail}`); throw e; } } @@ -864,9 +896,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { key: IOutboundGroupSessionKey, failedDevices: IOlmDevice[], ): Promise { - logger.debug( - `Notifying ${failedDevices.length} devices we failed to ` + `create Olm sessions in ${this.roomId}`, - ); + this.prefixedLogger.debug(`Notifying ${failedDevices.length} devices we failed to create Olm sessions`); // mark the devices that failed as "handled" because we don't want to try // to claim a one-time-key for dead devices on every message. @@ -877,9 +907,8 @@ export class MegolmEncryption extends EncryptionAlgorithm { } const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices); - logger.debug( - `Need to notify ${unnotifiedFailedDevices.length} failed devices ` + - `which haven't been notified before in ${this.roomId}`, + this.prefixedLogger.debug( + `Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`, ); const blockedMap: Record> = {}; for (const { userId, deviceInfo } of unnotifiedFailedDevices) { @@ -898,9 +927,8 @@ export class MegolmEncryption extends EncryptionAlgorithm { // send the notifications await this.notifyBlockedDevices(session, blockedMap); - logger.debug( - `Notified ${unnotifiedFailedDevices.length} devices we failed to ` + - `create Olm sessions in ${this.roomId}`, + this.prefixedLogger.debug( + `Notified ${unnotifiedFailedDevices.length} devices we failed to create Olm sessions`, ); } @@ -926,14 +954,14 @@ export class MegolmEncryption extends EncryptionAlgorithm { for (let i = 0; i < userDeviceMaps.length; i++) { try { await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); - logger.log( + this.prefixedLogger.log( `Completed blacklist notification for ${session.sessionId} ` + - `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`, + `(slice ${i + 1}/${userDeviceMaps.length})`, ); } catch (e) { - logger.log( - `blacklist notification for ${session.sessionId} in ` + - `${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`, + this.prefixedLogger.log( + `blacklist notification for ${session.sessionId} ` + + `(slice ${i + 1}/${userDeviceMaps.length}) failed`, ); throw e; @@ -948,24 +976,27 @@ export class MegolmEncryption extends EncryptionAlgorithm { * @param room - the room the event is in */ public prepareToEncrypt(room: Room): void { + if (room.roomId !== this.roomId) { + throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room"); + } + if (this.encryptionPreparation != null) { // We're already preparing something, so don't do anything else. // FIXME: check if we need to restart // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) const elapsedTime = Date.now() - this.encryptionPreparation.startTime; - logger.debug( - `Already started preparing to encrypt for ${this.roomId} ` + `${elapsedTime} ms ago, skipping`, + this.prefixedLogger.debug( + `Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`, ); return; } - logger.debug(`Preparing to encrypt events for ${this.roomId}`); + this.prefixedLogger.debug("Preparing to encrypt events"); this.encryptionPreparation = { startTime: Date.now(), promise: (async (): Promise => { try { - logger.debug(`Getting devices in ${this.roomId}`); const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); if (this.crypto.globalErrorOnUnknownDevices) { @@ -975,12 +1006,12 @@ export class MegolmEncryption extends EncryptionAlgorithm { this.removeUnknownDevices(devicesInRoom); } - logger.debug(`Ensuring outbound session in ${this.roomId}`); + this.prefixedLogger.debug("Ensuring outbound megolm session"); await this.ensureOutboundSession(room, devicesInRoom, blocked, true); - logger.debug(`Ready to encrypt events for ${this.roomId}`); + this.prefixedLogger.debug("Ready to encrypt events"); } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + this.prefixedLogger.error("Failed to prepare to encrypt events", e); } finally { delete this.encryptionPreparation; } @@ -994,7 +1025,7 @@ export class MegolmEncryption extends EncryptionAlgorithm { * @returns Promise which resolves to the new event body */ public async encryptMessage(room: Room, eventType: string, content: IContent): Promise { - logger.log(`Starting to encrypt event for ${this.roomId}`); + this.prefixedLogger.log("Starting to encrypt event"); if (this.encryptionPreparation != null) { // If we started sending keys, wait for it to be done. @@ -1146,6 +1177,11 @@ export class MegolmEncryption extends EncryptionAlgorithm { forceDistributeToUnverified = false, ): Promise<[DeviceInfoMap, IBlockedMap]> { const members = await room.getEncryptionTargetMembers(); + this.prefixedLogger.debug( + `Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`, + members.map((u) => `${u.userId} (${u.membership})`), + ); + const roomMembers = members.map(function (u) { return u.userId; }); @@ -1216,10 +1252,12 @@ export class MegolmDecryption extends DecryptionAlgorithm { private olmlib = olmlib; protected readonly roomId: string; + private readonly prefixedLogger: PrefixedLogger; public constructor(params: DecryptionClassParams>>) { super(params); this.roomId = params.roomId; + this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`); } /** @@ -1287,7 +1325,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { // event was sent. Use a fuzz factor of 2 minutes. const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000); if (problem) { - logger.info( + this.prefixedLogger.info( `When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` + `recent session problem with that sender: ${problem}`, ); @@ -1406,12 +1444,12 @@ export class MegolmDecryption extends DecryptionAlgorithm { const extraSessionData: OlmGroupSessionExtraData = {}; if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) { - logger.error("key event is missing fields"); + this.prefixedLogger.error("key event is missing fields"); return; } if (!olmlib.isOlmEncrypted(event)) { - logger.error("key event not properly encrypted"); + this.prefixedLogger.error("key event not properly encrypted"); return; } @@ -1426,7 +1464,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { senderKey, ); if (senderKeyUser !== event.getSender()) { - logger.error("sending device does not belong to the user it claims to be from"); + this.prefixedLogger.error("sending device does not belong to the user it claims to be from"); return; } const outgoingRequests = deviceInfo @@ -1453,7 +1491,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { // not one of our other devices and it's not shared // history, ignore it if (!extraSessionData.sharedHistory) { - logger.log("forwarded key not shared history - ignoring"); + this.prefixedLogger.log("forwarded key not shared history - ignoring"); return; } @@ -1461,7 +1499,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { // we're already in, and they're not one of our other // devices or the one who invited us, ignore it if (room && !fromInviter) { - logger.log("forwarded key not from inviter or from us - ignoring"); + this.prefixedLogger.log("forwarded key not from inviter or from us - ignoring"); return; } } @@ -1476,13 +1514,13 @@ export class MegolmDecryption extends DecryptionAlgorithm { forwardingKeyChain.push(senderKey); if (!content.sender_key) { - logger.error("forwarded_room_key event is missing sender_key field"); + this.prefixedLogger.error("forwarded_room_key event is missing sender_key field"); return; } const ed25519Key = content.sender_claimed_ed25519_key; if (!ed25519Key) { - logger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`); + this.prefixedLogger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`); return; } @@ -1506,7 +1544,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { "readwrite", ["parked_shared_history"], (txn) => this.crypto.cryptoStore.addParkedSharedHistory(content.room_id!, parkedData, txn), - logger.withPrefix("[addParkedSharedHistory]"), + this.prefixedLogger.withPrefix("[addParkedSharedHistory]"), ); return; } @@ -1563,7 +1601,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { // don't wait for the keys to be backed up for the server await this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); } catch (e) { - logger.error(`Error handling m.room_key_event: ${e}`); + this.prefixedLogger.error(`Error handling m.room_key_event: ${e}`); } } @@ -1575,74 +1613,91 @@ export class MegolmDecryption extends DecryptionAlgorithm { const senderKey = content.sender_key; if (content.code === "m.no_olm") { - const sender = event.getSender()!; - logger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`); - // if the sender says that they haven't been able to establish an olm - // session, let's proactively establish one + await this.onNoOlmWithheldEvent(event); + } else if (content.code === "m.unavailable") { + // this simply means that the other device didn't have the key, which isn't very useful information. Don't + // record it in the storage + } else { + await this.olmDevice.addInboundGroupSessionWithheld( + content.room_id, + senderKey, + content.session_id, + content.code, + content.reason, + ); + } - // Note: after we record that the olm session has had a problem, we - // trigger retrying decryption for all the messages from the sender's + // Having recorded the problem, retry decryption on any affected messages. + // It's unlikely we'll be able to decrypt sucessfully now, but this will + // update the error message. + // + if (content.session_id) { + await this.retryDecryption(senderKey, content.session_id); + } else { + // no_olm messages aren't specific to a given megolm session, so + // we trigger retrying decryption for all the messages from the sender's // key, so that we can update the error message to indicate the olm // session problem. + await this.retryDecryptionFromSender(senderKey); + } + } - if (await this.olmDevice.getSessionIdForDevice(senderKey)) { - // a session has already been established, so we don't need to - // create a new one. - logger.debug("New session already created. Not creating a new one."); - await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - return; - } - let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); + private async onNoOlmWithheldEvent(event: MatrixEvent): Promise { + const content = event.getContent(); + const senderKey = content.sender_key; + const sender = event.getSender()!; + this.prefixedLogger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + this.prefixedLogger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this.crypto.downloadKeys([sender], false); - device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey); - if (!device) { - logger.info("Couldn't find device for identity key " + senderKey + ": not establishing session"); - await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); - this.retryDecryptionFromSender(senderKey); - return; - } + this.prefixedLogger.info( + "Couldn't find device for identity key " + senderKey + ": not establishing session", + ); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + return; } + } - // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it? + // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it? - await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [sender]: [device] }, false); - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - undefined, - this.olmDevice, - sender, - device, - { type: "m.dummy" }, - ); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, { [sender]: [device] }, false); + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); - await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); - await this.baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - } else { - await this.olmDevice.addInboundGroupSessionWithheld( - content.room_id, - senderKey, - content.session_id, - content.code, - content.reason, - ); - } + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); } public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { @@ -1679,7 +1734,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { return null; } - logger.log( + this.prefixedLogger.log( "sharing keys for session " + body.sender_key + "|" + @@ -1778,7 +1833,7 @@ export class MegolmDecryption extends DecryptionAlgorithm { this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => { // This throws if the upload failed, but this is fine // since it will have written it to the db and will retry. - logger.log("Failed to back up megolm session", e); + this.prefixedLogger.log("Failed to back up megolm session", e); }); } // have another go at decrypting events sent with this session. @@ -1812,10 +1867,14 @@ export class MegolmDecryption extends DecryptionAlgorithm { return true; } - logger.debug("Retrying decryption on events", [...pending]); + const pendingList = [...pending]; + this.prefixedLogger.debug( + "Retrying decryption on events:", + pendingList.map((e) => `${e.getId()}`), + ); await Promise.all( - [...pending].map(async (ev) => { + pendingList.map(async (ev) => { try { await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted }); } catch (e) { @@ -1858,8 +1917,8 @@ export class MegolmDecryption extends DecryptionAlgorithm { await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); - logger.log( - `Sharing history in ${this.roomId} with users ${Object.keys(devicesByUser)}`, + this.prefixedLogger.log( + `Sharing history in with users ${Object.keys(devicesByUser)}`, sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`), ); for (const [senderKey, sessionId] of sharedHistorySessions) { @@ -1900,20 +1959,20 @@ export class MegolmDecryption extends DecryptionAlgorithm { for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning"); + this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning"); delete contentMap[userId][deviceId]; } } // No devices left for that user? Strip that too. if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); + this.prefixedLogger.log("Pruned all devices for user " + userId); delete contentMap[userId]; } } // Is there anything left? if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); + this.prefixedLogger.log("No users left to send to: aborting"); return; } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 441d569c4ac..b0a5783b32d 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -3433,6 +3433,8 @@ export class Crypto extends TypedEventEmitter) => MatrixEvent; @@ -55,6 +56,19 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event preventReEmit = true; } + // if there is a complete edit bundled alongside the event, perform the replacement. + // (prior to MSC3925, events were automatically replaced on the server-side. MSC3925 proposes that that doesn't + // happen automatically but the server does provide us with the whole content of the edit event.) + const bundledEdit = event.getServerAggregatedRelation>(RelationType.Replace); + if (bundledEdit?.content) { + const replacement = mapper(bundledEdit); + // XXX: it's worth noting that the spec says we should only respect encrypted edits if, once decrypted, the + // replacement has a `m.new_content` property. The problem is that we haven't yet decrypted the replacement + // (it should be happening in the background), so we can't enforce this. Possibly we should for decryption + // to complete, but that sounds a bit racy. For now, we just assume it's ok. + event.makeReplaced(replacement); + } + const thread = room?.findThreadForEvent(event); if (thread) { event.setThread(thread); diff --git a/src/extensible_events_v1/ExtensibleEvent.ts b/src/extensible_events_v1/ExtensibleEvent.ts new file mode 100644 index 00000000000..049659251b6 --- /dev/null +++ b/src/extensible_events_v1/ExtensibleEvent.ts @@ -0,0 +1,58 @@ +/* +Copyright 2021 - 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 { ExtensibleEventType, IPartialEvent } from "../@types/extensible_events"; + +/** + * Represents an Extensible Event in Matrix. + */ +export abstract class ExtensibleEvent { + protected constructor(public readonly wireFormat: IPartialEvent) {} + + /** + * Shortcut to wireFormat.content + */ + public get wireContent(): TContent { + return this.wireFormat.content; + } + + /** + * Serializes the event into a format which can be used to send the + * event to the room. + * @returns The serialized event. + */ + public abstract serialize(): IPartialEvent; + + /** + * Determines if this event is equivalent to the provided event type. + * This is recommended over `instanceof` checks due to issues in the JS + * runtime (and layering of dependencies in some projects). + * + * Implementations should pass this check off to their super classes + * if their own checks fail. Some primary implementations do not extend + * fallback classes given they support the primary type first. Thus, + * those classes may return false if asked about their fallback + * representation. + * + * Note that this only checks primary event types: legacy events, like + * m.room.message, should/will fail this check. + * @param primaryEventType - The (potentially namespaced) event + * type. + * @returns True if this event *could* be represented as the + * given type. + */ + public abstract isEquivalentTo(primaryEventType: ExtensibleEventType): boolean; +} diff --git a/src/extensible_events_v1/InvalidEventError.ts b/src/extensible_events_v1/InvalidEventError.ts new file mode 100644 index 00000000000..12e59ad62a2 --- /dev/null +++ b/src/extensible_events_v1/InvalidEventError.ts @@ -0,0 +1,24 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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. +*/ + +/** + * Thrown when an event is unforgivably unparsable. + */ +export class InvalidEventError extends Error { + public constructor(message: string) { + super(message); + } +} diff --git a/src/extensible_events_v1/MessageEvent.ts b/src/extensible_events_v1/MessageEvent.ts new file mode 100644 index 00000000000..3d049f45809 --- /dev/null +++ b/src/extensible_events_v1/MessageEvent.ts @@ -0,0 +1,145 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { Optional } from "matrix-events-sdk"; + +import { ExtensibleEvent } from "./ExtensibleEvent"; +import { + ExtensibleEventType, + IMessageRendering, + IPartialEvent, + isEventTypeSame, + M_HTML, + M_MESSAGE, + ExtensibleAnyMessageEventContent, + M_TEXT, +} from "../@types/extensible_events"; +import { isOptionalAString, isProvided } from "./utilities"; +import { InvalidEventError } from "./InvalidEventError"; + +/** + * Represents a message event. Message events are the simplest form of event with + * just text (optionally of different mimetypes, like HTML). + * + * Message events can additionally be an Emote or Notice, though typically those + * are represented as EmoteEvent and NoticeEvent respectively. + */ +export class MessageEvent extends ExtensibleEvent { + /** + * The default text for the event. + */ + public readonly text: string; + + /** + * The default HTML for the event, if provided. + */ + public readonly html: Optional; + + /** + * All the different renderings of the message. Note that this is the same + * format as an m.message body but may contain elements not found directly + * in the event content: this is because this is interpreted based off the + * other information available in the event. + */ + public readonly renderings: IMessageRendering[]; + + /** + * Creates a new MessageEvent from a pure format. Note that the event is + * *not* parsed here: it will be treated as a literal m.message primary + * typed event. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const mmessage = M_MESSAGE.findIn(this.wireContent); + const mtext = M_TEXT.findIn(this.wireContent); + const mhtml = M_HTML.findIn(this.wireContent); + if (isProvided(mmessage)) { + if (!Array.isArray(mmessage)) { + throw new InvalidEventError("m.message contents must be an array"); + } + const text = mmessage.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain"); + const html = mmessage.find((r) => r.mimetype === "text/html"); + + if (!text) throw new InvalidEventError("m.message is missing a plain text representation"); + + this.text = text.body; + this.html = html?.body; + this.renderings = mmessage; + } else if (isOptionalAString(mtext)) { + this.text = mtext; + this.html = mhtml; + this.renderings = [{ body: mtext, mimetype: "text/plain" }]; + if (this.html) { + this.renderings.push({ body: this.html, mimetype: "text/html" }); + } + } else { + throw new InvalidEventError("Missing textual representation for event"); + } + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_MESSAGE); + } + + protected serializeMMessageOnly(): ExtensibleAnyMessageEventContent { + let messageRendering: ExtensibleAnyMessageEventContent = { + [M_MESSAGE.name]: this.renderings, + }; + + // Use the shorthand if it's just a simple text event + if (this.renderings.length === 1) { + const mime = this.renderings[0].mimetype; + if (mime === undefined || mime === "text/plain") { + messageRendering = { + [M_TEXT.name]: this.renderings[0].body, + }; + } + } + + return messageRendering; + } + + public serialize(): IPartialEvent { + return { + type: "m.room.message", + content: { + ...this.serializeMMessageOnly(), + body: this.text, + msgtype: "m.text", + format: this.html ? "org.matrix.custom.html" : undefined, + formatted_body: this.html ?? undefined, + }, + }; + } + + /** + * Creates a new MessageEvent from text and HTML. + * @param text - The text. + * @param html - Optional HTML. + * @returns The representative message event. + */ + public static from(text: string, html?: string): MessageEvent { + return new MessageEvent({ + type: M_MESSAGE.name, + content: { + [M_TEXT.name]: text, + [M_HTML.name]: html, + }, + }); + } +} diff --git a/src/extensible_events_v1/PollEndEvent.ts b/src/extensible_events_v1/PollEndEvent.ts new file mode 100644 index 00000000000..243f1906acf --- /dev/null +++ b/src/extensible_events_v1/PollEndEvent.ts @@ -0,0 +1,97 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { + ExtensibleEventType, + IPartialEvent, + isEventTypeSame, + M_TEXT, + REFERENCE_RELATION, +} from "../@types/extensible_events"; +import { M_POLL_END, PollEndEventContent } from "../@types/polls"; +import { ExtensibleEvent } from "./ExtensibleEvent"; +import { InvalidEventError } from "./InvalidEventError"; +import { MessageEvent } from "./MessageEvent"; + +/** + * Represents a poll end/closure event. + */ +export class PollEndEvent extends ExtensibleEvent { + /** + * The poll start event ID referenced by the response. + */ + public readonly pollEventId: string; + + /** + * The closing message for the event. + */ + public readonly closingMessage: MessageEvent; + + /** + * Creates a new PollEndEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.response primary typed event. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const rel = this.wireContent["m.relates_to"]; + if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { + throw new InvalidEventError("Relationship must be a reference to an event"); + } + + this.pollEventId = rel.event_id; + this.closingMessage = new MessageEvent(this.wireFormat); + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_POLL_END); + } + + public serialize(): IPartialEvent { + return { + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: this.pollEventId, + }, + [M_POLL_END.name]: {}, + ...this.closingMessage.serialize().content, + }, + }; + } + + /** + * Creates a new PollEndEvent from a poll event ID. + * @param pollEventId - The poll start event ID. + * @param message - A closing message, typically revealing the top answer. + * @returns The representative poll closure event. + */ + public static from(pollEventId: string, message: string): PollEndEvent { + return new PollEndEvent({ + type: M_POLL_END.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: pollEventId, + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: message, + }, + }); + } +} diff --git a/src/extensible_events_v1/PollResponseEvent.ts b/src/extensible_events_v1/PollResponseEvent.ts new file mode 100644 index 00000000000..a61fc2e7cb6 --- /dev/null +++ b/src/extensible_events_v1/PollResponseEvent.ts @@ -0,0 +1,143 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { ExtensibleEvent } from "./ExtensibleEvent"; +import { M_POLL_RESPONSE, PollResponseEventContent, PollResponseSubtype } from "../@types/polls"; +import { ExtensibleEventType, IPartialEvent, isEventTypeSame, REFERENCE_RELATION } from "../@types/extensible_events"; +import { InvalidEventError } from "./InvalidEventError"; +import { PollStartEvent } from "./PollStartEvent"; + +/** + * Represents a poll response event. + */ +export class PollResponseEvent extends ExtensibleEvent { + private internalAnswerIds: string[] = []; + private internalSpoiled = false; + + /** + * The provided answers for the poll. Note that this may be falsy/unpredictable if + * the `spoiled` property is true. + */ + public get answerIds(): string[] { + return this.internalAnswerIds; + } + + /** + * The poll start event ID referenced by the response. + */ + public readonly pollEventId: string; + + /** + * Whether the vote is spoiled. + */ + public get spoiled(): boolean { + return this.internalSpoiled; + } + + /** + * Creates a new PollResponseEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.response primary typed event. + * + * To validate the response against a poll, call `validateAgainst` after creation. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const rel = this.wireContent["m.relates_to"]; + if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") { + throw new InvalidEventError("Relationship must be a reference to an event"); + } + + this.pollEventId = rel.event_id; + this.validateAgainst(null); + } + + /** + * Validates the poll response using the poll start event as a frame of reference. This + * is used to determine if the vote is spoiled, whether the answers are valid, etc. + * @param poll - The poll start event. + */ + public validateAgainst(poll: PollStartEvent | null): void { + const response = M_POLL_RESPONSE.findIn(this.wireContent); + if (!Array.isArray(response?.answers)) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + + let answers = response?.answers ?? []; + if (answers.some((a) => typeof a !== "string") || answers.length === 0) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + + if (poll) { + if (answers.some((a) => !poll.answers.some((pa) => pa.id === a))) { + this.internalSpoiled = true; + this.internalAnswerIds = []; + return; + } + + answers = answers.slice(0, poll.maxSelections); + } + + this.internalAnswerIds = answers; + this.internalSpoiled = false; + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_POLL_RESPONSE); + } + + public serialize(): IPartialEvent { + return { + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: this.pollEventId, + }, + [M_POLL_RESPONSE.name]: { + answers: this.spoiled ? undefined : this.answerIds, + }, + }, + }; + } + + /** + * Creates a new PollResponseEvent from a set of answers. To spoil the vote, pass an empty + * answers array. + * @param answers - The user's answers. Should be valid from a poll's answer IDs. + * @param pollEventId - The poll start event ID. + * @returns The representative poll response event. + */ + public static from(answers: string[], pollEventId: string): PollResponseEvent { + return new PollResponseEvent({ + type: M_POLL_RESPONSE.name, + content: { + "m.relates_to": { + rel_type: REFERENCE_RELATION.name, + event_id: pollEventId, + }, + [M_POLL_RESPONSE.name]: { + answers: answers, + }, + }, + }); + } +} diff --git a/src/extensible_events_v1/PollStartEvent.ts b/src/extensible_events_v1/PollStartEvent.ts new file mode 100644 index 00000000000..8584bf9e1fc --- /dev/null +++ b/src/extensible_events_v1/PollStartEvent.ts @@ -0,0 +1,207 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { NamespacedValue } from "matrix-events-sdk"; + +import { MessageEvent } from "./MessageEvent"; +import { ExtensibleEventType, IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events"; +import { + KnownPollKind, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_START, + PollStartEventContent, + PollStartSubtype, + PollAnswer, +} from "../@types/polls"; +import { InvalidEventError } from "./InvalidEventError"; +import { ExtensibleEvent } from "./ExtensibleEvent"; + +/** + * Represents a poll answer. Note that this is represented as a subtype and is + * not registered as a parsable event - it is implied for usage exclusively + * within the PollStartEvent parsing. + */ +export class PollAnswerSubevent extends MessageEvent { + /** + * The answer ID. + */ + public readonly id: string; + + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const id = wireFormat.content.id; + if (!id || typeof id !== "string") { + throw new InvalidEventError("Answer ID must be a non-empty string"); + } + this.id = id; + } + + public serialize(): IPartialEvent { + return { + type: "org.matrix.sdk.poll.answer", + content: { + id: this.id, + ...this.serializeMMessageOnly(), + }, + }; + } + + /** + * Creates a new PollAnswerSubevent from ID and text. + * @param id - The answer ID (unique within the poll). + * @param text - The text. + * @returns The representative answer. + */ + public static from(id: string, text: string): PollAnswerSubevent { + return new PollAnswerSubevent({ + type: "org.matrix.sdk.poll.answer", + content: { + id: id, + [M_TEXT.name]: text, + }, + }); + } +} + +/** + * Represents a poll start event. + */ +export class PollStartEvent extends ExtensibleEvent { + /** + * The question being asked, as a MessageEvent node. + */ + public readonly question: MessageEvent; + + /** + * The interpreted kind of poll. Note that this will infer a value that is known to the + * SDK rather than verbatim - this means unknown types will be represented as undisclosed + * polls. + * + * To get the raw kind, use rawKind. + */ + public readonly kind: KnownPollKind; + + /** + * The true kind as provided by the event sender. Might not be valid. + */ + public readonly rawKind: string; + + /** + * The maximum number of selections a user is allowed to make. + */ + public readonly maxSelections: number; + + /** + * The possible answers for the poll. + */ + public readonly answers: PollAnswerSubevent[]; + + /** + * Creates a new PollStartEvent from a pure format. Note that the event is *not* + * parsed here: it will be treated as a literal m.poll.start primary typed event. + * @param wireFormat - The event. + */ + public constructor(wireFormat: IPartialEvent) { + super(wireFormat); + + const poll = M_POLL_START.findIn(this.wireContent); + + if (!poll?.question) { + throw new InvalidEventError("A question is required"); + } + + this.question = new MessageEvent({ type: "org.matrix.sdk.poll.question", content: poll.question }); + + this.rawKind = poll.kind; + if (M_POLL_KIND_DISCLOSED.matches(this.rawKind)) { + this.kind = M_POLL_KIND_DISCLOSED; + } else { + this.kind = M_POLL_KIND_UNDISCLOSED; // default & assumed value + } + + this.maxSelections = + Number.isFinite(poll.max_selections) && poll.max_selections! > 0 ? poll.max_selections! : 1; + + if (!Array.isArray(poll.answers)) { + throw new InvalidEventError("Poll answers must be an array"); + } + const answers = poll.answers.slice(0, 20).map( + (a) => + new PollAnswerSubevent({ + type: "org.matrix.sdk.poll.answer", + content: a, + }), + ); + if (answers.length <= 0) { + throw new InvalidEventError("No answers available"); + } + this.answers = answers; + } + + public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean { + return isEventTypeSame(primaryEventType, M_POLL_START); + } + + public serialize(): IPartialEvent { + return { + type: M_POLL_START.name, + content: { + [M_POLL_START.name]: { + question: this.question.serialize().content, + kind: this.rawKind, + max_selections: this.maxSelections, + answers: this.answers.map((a) => a.serialize().content), + }, + [M_TEXT.name]: `${this.question.text}\n${this.answers.map((a, i) => `${i + 1}. ${a.text}`).join("\n")}`, + }, + }; + } + + /** + * Creates a new PollStartEvent from question, answers, and metadata. + * @param question - The question to ask. + * @param answers - The answers. Should be unique within each other. + * @param kind - The kind of poll. + * @param maxSelections - The maximum number of selections. Must be 1 or higher. + * @returns The representative poll start event. + */ + public static from( + question: string, + answers: string[], + kind: KnownPollKind | string, + maxSelections = 1, + ): PollStartEvent { + return new PollStartEvent({ + type: M_POLL_START.name, + content: { + [M_TEXT.name]: question, // unused by parsing + [M_POLL_START.name]: { + question: { [M_TEXT.name]: question }, + kind: kind instanceof NamespacedValue ? kind.name : kind, + max_selections: maxSelections, + answers: answers.map((a) => ({ id: makeId(), [M_TEXT.name]: a })), + }, + }, + }); + } +} + +const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +function makeId(): string { + return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join(""); +} diff --git a/src/extensible_events_v1/utilities.ts b/src/extensible_events_v1/utilities.ts new file mode 100644 index 00000000000..0660442ec31 --- /dev/null +++ b/src/extensible_events_v1/utilities.ts @@ -0,0 +1,35 @@ +/* +Copyright 2021 - 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 { Optional } from "matrix-events-sdk"; + +/** + * Determines if the given optional was provided a value. + * @param s - The optional to test. + * @returns True if the value is defined. + */ +export function isProvided(s: Optional): boolean { + return s !== null && s !== undefined; +} + +/** + * Determines if the given optional string is a defined string. + * @param s - The input string. + * @returns True if the input is a defined string. + */ +export function isOptionalAString(s: Optional): s is string { + return isProvided(s) && typeof s === "string"; +} diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index b48de46970b..667f4d3229a 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -16,6 +16,7 @@ limitations under the License. import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; import { + DecryptedRoomEvent, KeysBackupRequest, KeysClaimRequest, KeysQueryRequest, @@ -25,11 +26,13 @@ import { import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto"; import type { IToDeviceEvent } from "../sync-accumulator"; +import type { IEncryptedEventInfo } from "../crypto/api"; import { MatrixEvent } from "../models/event"; import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend"; import { logger } from "../logger"; import { IHttpOpts, MatrixHttpApi, Method } from "../http-api"; import { QueryDict } from "../utils"; +import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning"; /** * Common interface for all the request types returned by `OlmMachine.outgoingRequests`. @@ -74,8 +77,41 @@ export class RustCrypto implements CryptoBackend { } public async decryptEvent(event: MatrixEvent): Promise { - await this.olmMachine.decryptRoomEvent("event", new RustSdkCryptoJs.RoomId("room")); - throw new Error("not implemented"); + const res = (await this.olmMachine.decryptRoomEvent( + JSON.stringify({ + event_id: event.getId(), + type: event.getWireType(), + sender: event.getSender(), + state_key: event.getStateKey(), + content: event.getWireContent(), + origin_server_ts: event.getTs(), + }), + new RustSdkCryptoJs.RoomId(event.getRoomId()!), + )) as DecryptedRoomEvent; + return { + clearEvent: JSON.parse(res.event), + claimedEd25519Key: res.senderClaimedEd25519Key, + senderCurve25519Key: res.senderCurve25519Key, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + }; + } + + public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { + // TODO: make this work properly. Or better, replace it. + + const ret: Partial = {}; + + ret.senderKey = event.getSenderKey() ?? undefined; + ret.algorithm = event.getWireContent().algorithm; + + if (!ret.senderKey || !ret.algorithm) { + ret.encrypted = false; + return ret as IEncryptedEventInfo; + } + ret.encrypted = true; + ret.authenticated = true; + ret.mismatchedSender = true; + return ret as IEncryptedEventInfo; } public async userHasCrossSigningKeys(): Promise { @@ -88,6 +124,16 @@ export class RustCrypto implements CryptoBackend { return []; } + public checkUserTrust(userId: string): UserTrustLevel { + // TODO + return new UserTrustLevel(false, false, false); + } + + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { + // TODO + return new DeviceTrustLevel(false, false, false, false); + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // SyncCryptoCallbacks implementation diff --git a/src/sliding-sync.ts b/src/sliding-sync.ts index 3a2f6739135..dde5f1be73b 100644 --- a/src/sliding-sync.ts +++ b/src/sliding-sync.ts @@ -70,7 +70,7 @@ export interface MSC3575List extends MSC3575RoomSubscription { */ export interface MSC3575SlidingSyncRequest { // json body params - lists?: MSC3575List[]; + lists?: Record; unsubscribe_rooms?: string[]; room_subscriptions?: Record; extensions?: object; @@ -137,7 +137,7 @@ type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncO export interface MSC3575SlidingSyncResponse { pos: string; txn_id?: string; - lists: ListResponse[]; + lists: Record; rooms: Record; extensions: Record; } @@ -332,11 +332,7 @@ export type SlidingSyncEventHandlerMap = { resp: MSC3575SlidingSyncResponse | null, err?: Error, ) => void; - [SlidingSyncEvent.List]: ( - listIndex: number, - joinedCount: number, - roomIndexToRoomId: Record, - ) => void; + [SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record) => void; }; /** @@ -346,7 +342,7 @@ export type SlidingSyncEventHandlerMap = { * To hook this up with the JS SDK, you need to use SlidingSyncSdk. */ export class SlidingSync extends TypedEventEmitter { - private lists: SlidingList[]; + private lists: Map; private listModifiedCount = 0; private terminated = false; // flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :( @@ -380,13 +376,16 @@ export class SlidingSync extends TypedEventEmitter, private roomSubscriptionInfo: MSC3575RoomSubscription, private readonly client: MatrixClient, private readonly timeoutMS: number, ) { super(); - this.lists = lists.map((l) => new SlidingList(l)); + this.lists = new Map(); + lists.forEach((list, key) => { + this.lists.set(key, new SlidingList(list)); + }); } /** @@ -423,70 +422,70 @@ export class SlidingSync extends TypedEventEmitter } | null { - if (!this.lists[index]) { + public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record } | null { + const data = this.lists.get(key); + if (!data) { return null; } return { - joinedCount: this.lists[index].joinedCount, - roomIndexToRoomId: Object.assign({}, this.lists[index].roomIndexToRoomId), + joinedCount: data.joinedCount, + roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId), }; } /** - * Get the full list parameters for a list index. This function is provided for callers to use + * Get the full request list parameters for a list index. This function is provided for callers to use * in conjunction with setList to update fields on an existing list. - * @param index - The list index to get the list for. - * @returns A copy of the list or undefined. + * @param key - The list key to get the params for. + * @returns A copy of the list params or undefined. */ - public getList(index: number): MSC3575List | null { - if (!this.lists[index]) { + public getListParams(key: string): MSC3575List | null { + const params = this.lists.get(key); + if (!params) { return null; } - return this.lists[index].getList(true); + return params.getList(true); } /** * Set new ranges for an existing list. Calling this function when _only_ the ranges have changed * is more efficient than calling setList(index,list) as this function won't resend sticky params, * whereas setList always will. - * @param index - The list index to modify + * @param key - The list key to modify * @param ranges - The new ranges to apply. * @returns A promise which resolves to the transaction ID when it has been received down sync * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled * immediately after sending, in which case the action will be applied in the subsequent request) */ - public setListRanges(index: number, ranges: number[][]): Promise { - this.lists[index].updateListRange(ranges); + public setListRanges(key: string, ranges: number[][]): Promise { + const list = this.lists.get(key); + if (!list) { + return Promise.reject(new Error("no list with key " + key)); + } + list.updateListRange(ranges); return this.resend(); } /** * Add or replace a list. Calling this function will interrupt the /sync request to resend new * lists. - * @param index - The index to modify + * @param key - The key to modify * @param list - The new list parameters. * @returns A promise which resolves to the transaction ID when it has been received down sync * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled * immediately after sending, in which case the action will be applied in the subsequent request) */ - public setList(index: number, list: MSC3575List): Promise { - if (this.lists[index]) { - this.lists[index].replaceList(list); + public setList(key: string, list: MSC3575List): Promise { + const existingList = this.lists.get(key); + if (existingList) { + existingList.replaceList(list); + this.lists.set(key, existingList); } else { - this.lists[index] = new SlidingList(list); + this.lists.set(key, new SlidingList(list)); } this.listModifiedCount += 1; return this.resend(); @@ -592,32 +591,44 @@ export class SlidingSync extends TypedEventEmitter low; i--) { - if (this.lists[listIndex].isIndexInRange(i)) { - this.lists[listIndex].roomIndexToRoomId[i] = this.lists[listIndex].roomIndexToRoomId[i - 1]; + if (list.isIndexInRange(i)) { + list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1]; } } } - private shiftLeft(listIndex: number, hi: number, low: number): void { + private shiftLeft(listKey: string, hi: number, low: number): void { + const list = this.lists.get(listKey); + if (!list) { + return; + } // l h // 0,1,2,3,4 <- before // 0,1,3,4,4 <- after, low is deleted and hi is duplicated for (let i = low; i < hi; i++) { - if (this.lists[listIndex].isIndexInRange(i)) { - this.lists[listIndex].roomIndexToRoomId[i] = this.lists[listIndex].roomIndexToRoomId[i + 1]; + if (list.isIndexInRange(i)) { + list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1]; } } } - private removeEntry(listIndex: number, index: number): void { + private removeEntry(listKey: string, index: number): void { + const list = this.lists.get(listKey); + if (!list) { + return; + } // work out the max index let max = -1; - for (const n in this.lists[listIndex].roomIndexToRoomId) { + for (const n in list.roomIndexToRoomId) { if (Number(n) > max) { max = Number(n); } @@ -626,14 +637,18 @@ export class SlidingSync extends TypedEventEmitter max) { max = Number(n); } @@ -642,30 +657,37 @@ export class SlidingSync extends TypedEventEmitter { + if (!listData) { + return; + } switch (op.op) { case "DELETE": { - logger.debug("DELETE", listIndex, op.index, ";"); - delete this.lists[listIndex].roomIndexToRoomId[op.index]; + logger.debug("DELETE", listKey, op.index, ";"); + delete listData.roomIndexToRoomId[op.index]; if (gapIndex !== -1) { // we already have a DELETE operation to process, so process it. - this.removeEntry(listIndex, gapIndex); + this.removeEntry(listKey, gapIndex); } gapIndex = op.index; break; } case "INSERT": { - logger.debug("INSERT", listIndex, op.index, op.room_id, ";"); - if (this.lists[listIndex].roomIndexToRoomId[op.index]) { + logger.debug("INSERT", listKey, op.index, op.room_id, ";"); + if (listData.roomIndexToRoomId[op.index]) { // something is in this space, shift items out of the way if (gapIndex < 0) { // we haven't been told where to shift from, so make way for a new room entry. - this.addEntry(listIndex, op.index); + this.addEntry(listKey, op.index); } else if (gapIndex > op.index) { // the gap is further down the list, shift every element to the right // starting at the gap so we can just shift each element in turn: @@ -674,11 +696,11 @@ export class SlidingSync extends TypedEventEmitter = {}; + this.lists.forEach((l: SlidingList, key: string) => { + reqLists[key] = l.getList(false); + }); const reqBody: MSC3575SlidingSyncRequest = { - lists: this.lists.map((l) => { - return l.getList(false); - }), + lists: reqLists, pos: currentPos, timeout: this.timeoutMS, clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS, @@ -866,11 +890,15 @@ export class SlidingSync extends TypedEventEmitter { - this.lists[i].joinedCount = val.count; + Object.keys(resp.lists).forEach((key: string) => { + const list = this.lists.get(key); + if (!list || !resp) { + return; + } + list.joinedCount = resp.lists[key].count; }); this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp); } catch (err) { @@ -899,25 +927,24 @@ export class SlidingSync extends TypedEventEmitter = new Set(); + const listKeysWithUpdates: Set = new Set(); if (!doNotUpdateList) { - resp.lists.forEach((list, listIndex) => { + for (const [key, list] of Object.entries(resp.lists)) { list.ops = list.ops || []; if (list.ops.length > 0) { - listIndexesWithUpdates.add(listIndex); + listKeysWithUpdates.add(key); } - this.processListOps(list, listIndex); - }); + this.processListOps(list, key); + } } this.invokeLifecycleListeners(SlidingSyncState.Complete, resp); this.onPostExtensionsResponse(resp.extensions); - listIndexesWithUpdates.forEach((i) => { - this.emit( - SlidingSyncEvent.List, - i, - this.lists[i].joinedCount, - Object.assign({}, this.lists[i].roomIndexToRoomId), - ); + listKeysWithUpdates.forEach((listKey: string) => { + const list = this.lists.get(listKey); + if (!list) { + return; + } + this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId)); }); this.resolveTransactionDefers(resp.txn_id); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index d37df7cda6a..8f88e34ba04 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -236,7 +236,7 @@ export enum CallErrorCode { /** * We transferred the call off to somewhere else */ - Transfered = "transferred", + Transferred = "transferred", /** * A call from the same user was found with a new session id @@ -786,9 +786,9 @@ export class MatrixCall extends TypedEventEmitter t.sender === newSender); - if (newTransciever) { - this.transceivers.set(tKey, newTransciever); + const newTransceiver = this.peerConn!.getTransceivers().find((t) => t.sender === newSender); + if (newTransceiver) { + this.transceivers.set(tKey, newTransceiver); } else { logger.warn("Didn't find a matching transceiver after adding track!"); } @@ -1344,9 +1344,9 @@ export class MatrixCall extends TypedEventEmitter t.sender === newSender); - if (newTransciever) { - this.transceivers.set(tKey, newTransciever); + const newTransceiver = this.peerConn!.getTransceivers().find((t) => t.sender === newSender); + if (newTransceiver) { + this.transceivers.set(tKey, newTransceiver); } else { logger.warn("Couldn't find matching transceiver for newly added track!"); } @@ -1610,25 +1610,25 @@ export class MatrixCall extends TypedEventEmitter { @@ -2504,7 +2504,7 @@ export class MatrixCall extends TypedEventEmitter /** * Replaces the current MediaStream with a new one. - * The stream will be different and new stream as remore parties are + * The stream will be different and new stream as remote parties are * concerned, but this can be used for convenience locally to set up * volume listeners automatically on the new stream etc. * @param newStream - new stream with which to replace the current one diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index 018392cff8e..cbbd7b8a306 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -208,6 +208,7 @@ export class GroupCall extends TypedEventEmitter< private resendMemberStateTimer: ReturnType | null = null; private initWithAudioMuted = false; private initWithVideoMuted = false; + private initCallFeedPromise?: Promise; public constructor( private client: MatrixClient, @@ -347,36 +348,43 @@ export class GroupCall extends TypedEventEmitter< ); } - public async initLocalCallFeed(): Promise { - logger.log(`groupCall ${this.groupCallId} initLocalCallFeed`); - + public async initLocalCallFeed(): Promise { if (this.state !== GroupCallState.LocalCallFeedUninitialized) { throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`); } - this.state = GroupCallState.InitializingLocalCallFeed; - let stream: MediaStream; + // wraps the real method to serialise calls, because we don't want to try starting + // multiple call feeds at once + if (this.initCallFeedPromise) return this.initCallFeedPromise; - let disposed = false; - const onState = (state: GroupCallState): void => { - if (state === GroupCallState.LocalCallFeedUninitialized) { - disposed = true; - } - }; - this.on(GroupCallEvent.GroupCallStateChanged, onState); + try { + this.initCallFeedPromise = this.initLocalCallFeedInternal(); + await this.initCallFeedPromise; + } finally { + this.initCallFeedPromise = undefined; + } + } + + private async initLocalCallFeedInternal(): Promise { + logger.log(`groupCall ${this.groupCallId} initLocalCallFeed`); + + let stream: MediaStream; try { stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video); } catch (error) { this.state = GroupCallState.LocalCallFeedUninitialized; throw error; - } finally { - this.off(GroupCallEvent.GroupCallStateChanged, onState); } - // The call could've been disposed while we were waiting - if (disposed) throw new Error("Group call disposed"); + // The call could've been disposed while we were waiting, and could + // also have been started back up again (hello, React 18) so if we're + // still in this 'initializing' state, carry on, otherwise bail. + if (this._state !== GroupCallState.InitializingLocalCallFeed) { + this.client.getMediaHandler().stopUserMediaStream(stream); + throw new Error("Group call disposed while gathering media stream"); + } const callFeed = new CallFeed({ client: this.client, @@ -396,8 +404,6 @@ export class GroupCall extends TypedEventEmitter< this.addUserMediaFeed(callFeed); this.state = GroupCallState.LocalCallFeedInitialized; - - return callFeed; } public async updateLocalUsermediaStream(stream: MediaStream): Promise { @@ -575,7 +581,7 @@ export class GroupCall extends TypedEventEmitter< ); this.localCallFeed.setAudioVideoMuted(muted, null); // I don't believe its actually necessary to enable these tracks: they - // are the one on the groupcall's own CallFeed and are cloned before being + // are the one on the GroupCall's own CallFeed and are cloned before being // given to any of the actual calls, so these tracks don't actually go // anywhere. Let's do it anyway to avoid confusion. setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted); @@ -1182,6 +1188,14 @@ export class GroupCall extends TypedEventEmitter< * Recalculates and updates the participant map to match the room state. */ private updateParticipants(): void { + const localMember = this.room.getMember(this.client.getUserId()!)!; + if (!localMember) { + // The client hasn't fetched enough of the room state to get our own member + // event. This probably shouldn't happen, but sanity check & exit for now. + logger.warn("Tried to update participants before local room member is available"); + return; + } + if (this.participantsExpirationTimer !== null) { clearTimeout(this.participantsExpirationTimer); this.participantsExpirationTimer = null; @@ -1236,7 +1250,6 @@ export class GroupCall extends TypedEventEmitter< // Apply local echo for the entered case if (entered) { - const localMember = this.room.getMember(this.client.getUserId()!)!; let deviceMap = participants.get(localMember); if (deviceMap === undefined) { deviceMap = new Map(); diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index 338701d7189..b167ce593ba 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -57,6 +57,9 @@ export class MediaHandler extends TypedEventEmitter< public userMediaStreams: MediaStream[] = []; public screensharingStreams: MediaStream[] = []; + // Promise chain to serialise calls to getMediaStream + private getMediaStreamPromise?: Promise; + public constructor(private client: MatrixClient) { super(); } @@ -196,6 +199,19 @@ export class MediaHandler extends TypedEventEmitter< * @returns based on passed parameters */ public async getUserMediaStream(audio: boolean, video: boolean, reusable = true): Promise { + // Serialise calls, othertwise we can't sensibly re-use the stream + if (this.getMediaStreamPromise) { + this.getMediaStreamPromise = this.getMediaStreamPromise.then(() => { + return this.getUserMediaStreamInternal(audio, video, reusable); + }); + } else { + this.getMediaStreamPromise = this.getUserMediaStreamInternal(audio, video, reusable); + } + + return this.getMediaStreamPromise; + } + + private async getUserMediaStreamInternal(audio: boolean, video: boolean, reusable: boolean): Promise { const shouldRequestAudio = audio && (await this.hasAudioDevice()); const shouldRequestVideo = video && (await this.hasVideoDevice()); diff --git a/yarn.lock b/yarn.lock index 03a855db843..a9776dac67d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,24 +64,24 @@ integrity sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg== "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.5": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.7.tgz#37072f951bd4d28315445f66e0ec9f6ae0c8c35f" - integrity sha512-t1ZjCluspe5DW24bn2Rr1CDb2v9rn/hROtg9a2tmd0+QYf4bsloYfLQzjG4qHPNMhWtKdGC33R5AxGR2Af2cBw== + version "7.20.12" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.12.tgz#7930db57443c6714ad216953d1356dac0eb8496d" + integrity sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" "@babel/generator" "^7.20.7" "@babel/helper-compilation-targets" "^7.20.7" - "@babel/helper-module-transforms" "^7.20.7" + "@babel/helper-module-transforms" "^7.20.11" "@babel/helpers" "^7.20.7" "@babel/parser" "^7.20.7" "@babel/template" "^7.20.7" - "@babel/traverse" "^7.20.7" + "@babel/traverse" "^7.20.12" "@babel/types" "^7.20.7" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" - json5 "^2.2.1" + json5 "^2.2.2" semver "^6.3.0" "@babel/eslint-parser@^7.12.10": @@ -209,7 +209,7 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.20.7": +"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11": version "7.20.11" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz#df4c7af713c557938c50ea3ad0117a7944b2f1b0" integrity sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg== @@ -1004,7 +1004,7 @@ "@babel/parser" "^7.20.7" "@babel/types" "^7.20.7" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.20.10", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.1.6", "@babel/traverse@^7.20.5", "@babel/traverse@^7.7.2": version "7.20.10" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.10.tgz#2bf98239597fcec12f842756f186a9dde6d09230" integrity sha512-oSf1juCgymrSez8NI4A2sr4+uB/mFd9MXplYGPEBnfAuWmmyeVcHa6xLPiaRBcXkcb/28bgxmQLTVwFKE1yfsg== @@ -1020,6 +1020,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.20.10", "@babel/traverse@^7.20.12", "@babel/traverse@^7.20.7": + version "7.20.12" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.12.tgz#7f0f787b3a67ca4475adef1f56cb94f6abd4a4b5" + integrity sha512-MsIbFN0u+raeja38qboyF8TIT7K0BFzz/Yd/77ta4MsUsmP2RAnidIlwq7d5HFQrH/OZJecGV6B71C4zAgpoSQ== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.20.7" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.7.tgz#54ec75e252318423fc07fb644dc6a58a64c09b7f" @@ -1059,7 +1075,7 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint/eslintrc@^1.3.3": +"@eslint/eslintrc@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" integrity sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA== @@ -1074,7 +1090,7 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@humanwhocodes/config-array@^0.11.6": +"@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" integrity sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g== @@ -1762,13 +1778,13 @@ "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^5.45.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.0.tgz#54f8368d080eb384a455f60c2ee044e948a8ce67" - integrity sha512-SVLafp0NXpoJY7ut6VFVUU9I+YeFsDzeQwtK0WZ+xbRN3mtxJ08je+6Oi2N89qDn087COdO0u3blKZNv9VetRQ== + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.1.tgz#deee67e399f2cb6b4608c935777110e509d8018c" + integrity sha512-9nY5K1Rp2ppmpb9s9S2aBiF3xo5uExCehMDmYmmFqqyxgenbHJ3qbarcLt4ITgaD6r/2ypdlcFRdcuVPnks+fQ== dependencies: - "@typescript-eslint/scope-manager" "5.48.0" - "@typescript-eslint/type-utils" "5.48.0" - "@typescript-eslint/utils" "5.48.0" + "@typescript-eslint/scope-manager" "5.48.1" + "@typescript-eslint/type-utils" "5.48.1" + "@typescript-eslint/utils" "5.48.1" debug "^4.3.4" ignore "^5.2.0" natural-compare-lite "^1.4.0" @@ -1777,71 +1793,71 @@ tsutils "^3.21.0" "@typescript-eslint/parser@^5.45.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.48.0.tgz#02803355b23884a83e543755349809a50b7ed9ba" - integrity sha512-1mxNA8qfgxX8kBvRDIHEzrRGrKHQfQlbW6iHyfHYS0Q4X1af+S6mkLNtgCOsGVl8+/LUPrqdHMssAemkrQ01qg== + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.48.1.tgz#d0125792dab7e232035434ab8ef0658154db2f10" + integrity sha512-4yg+FJR/V1M9Xoq56SF9Iygqm+r5LMXvheo6DQ7/yUWynQ4YfCRnsKuRgqH4EQ5Ya76rVwlEpw4Xu+TgWQUcdA== dependencies: - "@typescript-eslint/scope-manager" "5.48.0" - "@typescript-eslint/types" "5.48.0" - "@typescript-eslint/typescript-estree" "5.48.0" + "@typescript-eslint/scope-manager" "5.48.1" + "@typescript-eslint/types" "5.48.1" + "@typescript-eslint/typescript-estree" "5.48.1" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.48.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.48.0.tgz#607731cb0957fbc52fd754fd79507d1b6659cecf" - integrity sha512-0AA4LviDtVtZqlyUQnZMVHydDATpD9SAX/RC5qh6cBd3xmyWvmXYF+WT1oOmxkeMnWDlUVTwdODeucUnjz3gow== +"@typescript-eslint/scope-manager@5.48.1": + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.48.1.tgz#39c71e4de639f5fe08b988005beaaf6d79f9d64d" + integrity sha512-S035ueRrbxRMKvSTv9vJKIWgr86BD8s3RqoRZmsSh/s8HhIs90g6UlK8ZabUSjUZQkhVxt7nmZ63VJ9dcZhtDQ== dependencies: - "@typescript-eslint/types" "5.48.0" - "@typescript-eslint/visitor-keys" "5.48.0" + "@typescript-eslint/types" "5.48.1" + "@typescript-eslint/visitor-keys" "5.48.1" -"@typescript-eslint/type-utils@5.48.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.48.0.tgz#40496dccfdc2daa14a565f8be80ad1ae3882d6d6" - integrity sha512-vbtPO5sJyFjtHkGlGK4Sthmta0Bbls4Onv0bEqOGm7hP9h8UpRsHJwsrCiWtCUndTRNQO/qe6Ijz9rnT/DB+7g== +"@typescript-eslint/type-utils@5.48.1": + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.48.1.tgz#5d94ac0c269a81a91ad77c03407cea2caf481412" + integrity sha512-Hyr8HU8Alcuva1ppmqSYtM/Gp0q4JOp1F+/JH5D1IZm/bUBrV0edoewQZiEc1r6I8L4JL21broddxK8HAcZiqQ== dependencies: - "@typescript-eslint/typescript-estree" "5.48.0" - "@typescript-eslint/utils" "5.48.0" + "@typescript-eslint/typescript-estree" "5.48.1" + "@typescript-eslint/utils" "5.48.1" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.48.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.0.tgz#d725da8dfcff320aab2ac6f65c97b0df30058449" - integrity sha512-UTe67B0Ypius0fnEE518NB2N8gGutIlTojeTg4nt0GQvikReVkurqxd2LvYa9q9M5MQ6rtpNyWTBxdscw40Xhw== +"@typescript-eslint/types@5.48.1": + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.1.tgz#efd1913a9aaf67caf8a6e6779fd53e14e8587e14" + integrity sha512-xHyDLU6MSuEEdIlzrrAerCGS3T7AA/L8Hggd0RCYBi0w3JMvGYxlLlXHeg50JI9Tfg5MrtsfuNxbS/3zF1/ATg== -"@typescript-eslint/typescript-estree@5.48.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.48.0.tgz#a7f04bccb001003405bb5452d43953a382c2fac2" - integrity sha512-7pjd94vvIjI1zTz6aq/5wwE/YrfIyEPLtGJmRfyNR9NYIW+rOvzzUv3Cmq2hRKpvt6e9vpvPUQ7puzX7VSmsEw== +"@typescript-eslint/typescript-estree@5.48.1": + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.48.1.tgz#9efa8ee2aa471c6ab62e649f6e64d8d121bc2056" + integrity sha512-Hut+Osk5FYr+sgFh8J/FHjqX6HFcDzTlWLrFqGoK5kVUN3VBHF/QzZmAsIXCQ8T/W9nQNBTqalxi1P3LSqWnRA== dependencies: - "@typescript-eslint/types" "5.48.0" - "@typescript-eslint/visitor-keys" "5.48.0" + "@typescript-eslint/types" "5.48.1" + "@typescript-eslint/visitor-keys" "5.48.1" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.48.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.48.0.tgz#eee926af2733f7156ad8d15e51791e42ce300273" - integrity sha512-x2jrMcPaMfsHRRIkL+x96++xdzvrdBCnYRd5QiW5Wgo1OB4kDYPbC1XjWP/TNqlfK93K/lUL92erq5zPLgFScQ== +"@typescript-eslint/utils@5.48.1": + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.48.1.tgz#20f2f4e88e9e2a0961cbebcb47a1f0f7da7ba7f9" + integrity sha512-SmQuSrCGUOdmGMwivW14Z0Lj8dxG1mOFZ7soeJ0TQZEJcs3n5Ndgkg0A4bcMFzBELqLJ6GTHnEU+iIoaD6hFGA== dependencies: "@types/json-schema" "^7.0.9" "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.48.0" - "@typescript-eslint/types" "5.48.0" - "@typescript-eslint/typescript-estree" "5.48.0" + "@typescript-eslint/scope-manager" "5.48.1" + "@typescript-eslint/types" "5.48.1" + "@typescript-eslint/typescript-estree" "5.48.1" eslint-scope "^5.1.1" eslint-utils "^3.0.0" semver "^7.3.7" -"@typescript-eslint/visitor-keys@5.48.0": - version "5.48.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.48.0.tgz#4446d5e7f6cadde7140390c0e284c8702d944904" - integrity sha512-5motVPz5EgxQ0bHjut3chzBkJ3Z3sheYVcSwS5BpHZpLqSptSmELNtGixmgj65+rIfhvtQTz5i9OP2vtzdDH7Q== +"@typescript-eslint/visitor-keys@5.48.1": + version "5.48.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.48.1.tgz#79fd4fb9996023ef86849bf6f904f33eb6c8fccb" + integrity sha512-Ns0XBwmfuX7ZknznfXozgnydyR8F6ev/KEGePP4i74uL3ArsKbEhJ7raeKr1JSa997DBDwol/4a0Y+At82c9dA== dependencies: - "@typescript-eslint/types" "5.48.0" + "@typescript-eslint/types" "5.48.1" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.3: @@ -2536,9 +2552,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001400: - version "1.0.30001441" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz#987437b266260b640a23cd18fbddb509d7f69f3e" - integrity sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg== + version "1.0.30001442" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001442.tgz#40337f1cf3be7c637b061e2f78582dc1daec0614" + integrity sha512-239m03Pqy0hwxYPYR5JwOIxRJfLTWtle9FV8zosfV5pHg+/51uD4nxcUlM8+mWWGfwKtt8lJNHnD3cWw9VZ6ow== center-align@^0.1.1: version "0.1.3" @@ -3053,9 +3069,9 @@ dir-glob@^3.0.1: path-type "^4.0.0" docdash@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/docdash/-/docdash-2.0.0.tgz#1db24c934f9d4feb5576c638e8c061deb5ae1832" - integrity sha512-AxxZwrMLmiArEHJirdyZmW6YTBKxkd/Z5V9U2EU1crIMtpgoU/cH7Hnc9n1E0lzB1ZSam+VVMSnvlc+9+GUGVg== + version "2.0.1" + resolved "https://registry.yarnpkg.com/docdash/-/docdash-2.0.1.tgz#ac36dd1b64a2ae298e642c9a8d8d3c0f7f0a2c55" + integrity sha512-mkBhkeMyMwGV4YIdA7S4dIC25ENrfU/ZBfyTs/MXj/HUewW/dtx44xoho4PttCOMsqxlcghzfj8HRlam5QiSoQ== dependencies: "@jsdoc/salty" "^0.2.1" @@ -3410,13 +3426,13 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.29.0: - version "8.29.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.29.0.tgz#d74a88a20fb44d59c51851625bc4ee8d0ec43f87" - integrity sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg== +eslint@8.31.0: + version "8.31.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.31.0.tgz#75028e77cbcff102a9feae1d718135931532d524" + integrity sha512-0tQQEVdmPZ1UtUKXjX7EMm9BlgJ08G90IhWh0PKDCb3ZLsgAOHI8fYSIzYVZej92zsgq+ft0FGsxhJ3xo2tbuA== dependencies: - "@eslint/eslintrc" "^1.3.3" - "@humanwhocodes/config-array" "^0.11.6" + "@eslint/eslintrc" "^1.4.1" + "@humanwhocodes/config-array" "^0.11.8" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" ajv "^6.10.0" @@ -3435,7 +3451,7 @@ eslint@8.29.0: file-entry-cache "^6.0.1" find-up "^5.0.0" glob-parent "^6.0.2" - globals "^13.15.0" + globals "^13.19.0" grapheme-splitter "^1.0.4" ignore "^5.2.0" import-fresh "^3.0.0" @@ -3835,7 +3851,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.15.0, globals@^13.19.0: +globals@^13.19.0: version "13.19.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.19.0.tgz#7a42de8e6ad4f7242fbcca27ea5b23aca367b5c8" integrity sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ== @@ -4565,9 +4581,9 @@ jest-leak-detector@^29.3.1: pretty-format "^29.3.1" jest-localstorage-mock@^2.4.6: - version "2.4.25" - resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.25.tgz#9be525ebcd4eb791a445dbeba8474ceb2abeb434" - integrity sha512-VdQ8PTpNzUJDx/KY3hBrTwxqVMzMS+LccngC15EZSFdxJ+VeeCYmyW7BSzubk9FUKCVeXPjYPibzXe6swXYA+g== + version "2.4.26" + resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz#7d57fb3555f2ed5b7ed16fd8423fd81f95e9e8db" + integrity sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w== jest-matcher-utils@^28.1.3: version "28.1.3" @@ -4925,12 +4941,12 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.2.1: +json5@^2.2.2: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonc-parser@^3.0.0: +jsonc-parser@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== @@ -5129,7 +5145,7 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -marked@^4.2.4: +marked@^4.2.5: version "4.2.5" resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.5.tgz#979813dfc1252cc123a79b71b095759a32f42a5d" integrity sha512-jPueVhumq7idETHkb203WDD4fMA3yV9emQ5vLwop58lu8bTclMghBWcYAavlDqIEMaisADinV1TooIFCfqOsYQ== @@ -5242,7 +5258,7 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatc dependencies: brace-expansion "^1.1.7" -minimatch@^5.1.1: +minimatch@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.2.tgz#0939d7d6f0898acbd1508abe534d1929368a8fff" integrity sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg== @@ -5667,10 +5683,10 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.1.tgz#4e1fd11c34e2421bc1da9aea9bd8127cd0a35efc" - integrity sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg== +prettier@2.8.2: + version "2.8.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.2.tgz#c4ea1b5b454d7c4b59966db2e06ed7eec5dfd160" + integrity sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw== pretty-format@^28.1.3: version "28.1.3" @@ -6287,14 +6303,14 @@ shell-quote@^1.6.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.4.tgz#33fe15dee71ab2a81fcbd3a52106c5cfb9fb75d8" integrity sha512-8o/QEhSSRb1a5i7TFR0iM4G16Z0vYB2OQVs4G3aAFXjn3T6yEx8AZxy1PgDF7I00LZHYA3WxaSYIf5e5sAX8Rw== -shiki@^0.11.1: - version "0.11.1" - resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.11.1.tgz#df0f719e7ab592c484d8b73ec10e215a503ab8cc" - integrity sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA== +shiki@^0.12.1: + version "0.12.1" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.12.1.tgz#26fce51da12d055f479a091a5307470786f300cd" + integrity sha512-aieaV1m349rZINEBkjxh2QbBvFFQOlgqYTNtCal82hHj4dDZ76oMlQIX+C7ryerBTDiga3e5NfH6smjdJ02BbQ== dependencies: - jsonc-parser "^3.0.0" - vscode-oniguruma "^1.6.1" - vscode-textmate "^6.0.0" + jsonc-parser "^3.2.0" + vscode-oniguruma "^1.7.0" + vscode-textmate "^8.0.0" side-channel@^1.0.4: version "1.0.4" @@ -6831,14 +6847,14 @@ typedoc-plugin-missing-exports@^1.0.0: integrity sha512-7s6znXnuAj1eD9KYPyzVzR1lBF5nwAY8IKccP5sdoO9crG4lpd16RoFpLsh2PccJM+I2NASpr0+/NMka6ThwVA== typedoc@^0.23.20: - version "0.23.23" - resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.23.tgz#9cf95b03d2d40031d8978b55e88b0b968d69f512" - integrity sha512-cg1YQWj+/BU6wq74iott513U16fbrPCbyYs04PHZgvoKJIc6EY4xNobyDZh4KMfRGW8Yjv6wwIzQyoqopKOUGw== + version "0.23.24" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.24.tgz#01cf32c09f2c19362e72a9ce1552d6e5b48c4fef" + integrity sha512-bfmy8lNQh+WrPYcJbtjQ6JEEsVl/ce1ZIXyXhyW+a1vFrjO39t6J8sL/d6FfAGrJTc7McCXgk9AanYBSNvLdIA== dependencies: lunr "^2.3.9" - marked "^4.2.4" - minimatch "^5.1.1" - shiki "^0.11.1" + marked "^4.2.5" + minimatch "^5.1.2" + shiki "^0.12.1" typescript@^3.2.2: version "3.9.10" @@ -7039,15 +7055,15 @@ void-elements@^2.0.1: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung== -vscode-oniguruma@^1.6.1: +vscode-oniguruma@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz#439bfad8fe71abd7798338d1cd3dc53a8beea94b" integrity sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA== -vscode-textmate@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-6.0.0.tgz#a3777197235036814ac9a92451492f2748589210" - integrity sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ== +vscode-textmate@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-8.0.0.tgz#2c7a3b1163ef0441097e0b5d6389cd5504b59e5d" + integrity sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg== vue-docgen-api@^3.26.0: version "3.26.0"