1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-07 23:02:56 +03:00

Merge branch 'develop' into madlittlemods/stablize-msc3030-timestamp-to-event

This commit is contained in:
Andy Balaam
2023-01-06 15:38:08 +00:00
committed by GitHub
53 changed files with 5895 additions and 4735 deletions

View File

@@ -24,7 +24,7 @@ import MockHttpBackend from "matrix-mock-request";
import { LocalStorageCryptoStore } from "../src/crypto/store/localStorage-crypto-store";
import { logger } from "../src/logger";
import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix";
import { createClient, IStartClientOpts } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils";
@@ -79,9 +79,12 @@ export class TestClient {
/**
* start the client, and wait for it to initialise.
*/
public start(): Promise<void> {
public start(opts: IStartClientOpts = {}): Promise<void> {
logger.log(this + ": starting");
this.httpBackend.when("GET", "/versions").respond(200, {});
this.httpBackend.when("GET", "/versions").respond(200, {
// we have tests that rely on support for lazy-loading members
versions: ["r0.5.0"],
});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload();
@@ -93,6 +96,8 @@ export class TestClient {
this.client.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached,
...opts,
});
return Promise.all([this.httpBackend.flushAllExpected(), syncPromise(this.client)]).then(() => {

View File

@@ -1016,6 +1016,61 @@ describe("MatrixClient event timelines", function () {
httpBackend.flushAllExpected(),
]);
});
it("should create threads for thread roots discovered", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/messages")
.check(function (req) {
const params = req.queryParams!;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
})
.respond(200, function () {
return {
chunk: [EVENTS[1], EVENTS[2], THREAD_ROOT],
end: "start_token1",
};
});
let tl: EventTimeline;
return Promise.all([
client
.getEventTimeline(timelineSet, EVENTS[0].event_id!)
.then(function (tl0) {
tl = tl0!;
return client.paginateEventTimeline(tl, { backwards: true });
})
.then(function (success) {
expect(success).toBeTruthy();
expect(tl!.getEvents().length).toEqual(4);
expect(tl!.getEvents()[0].event).toEqual(THREAD_ROOT);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]);
expect(tl!.getEvents()[2].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[3].event).toEqual(EVENTS[0]);
expect(room.getThreads().map((it) => it.id)).toEqual([THREAD_ROOT.event_id!]);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token1");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
}),
httpBackend.flushAllExpected(),
]);
});
});
it("should ensure thread events are ordered correctly", async () => {

View File

@@ -35,9 +35,7 @@ describe("MatrixClient", function () {
let store: MemoryStore | undefined;
const defaultClientOpts: IStoredClientOpts = {
canResetEntireTimeline: (roomId) => false,
experimentalThreadSupport: false,
crypto: {} as unknown as IStoredClientOpts["crypto"],
};
const setupTests = (): [MatrixClient, HttpBackend, MemoryStore] => {
const store = new MemoryStore();

View File

@@ -1543,6 +1543,52 @@ describe("MatrixClient syncing", () => {
});
});
});
it("only replays receipts relevant to the current context", async () => {
const THREAD_ID = "$unknownthread:localhost";
const receipt = {
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event1:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 666, thread_id: THREAD_ID },
},
},
"$otherevent:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 999, thread_id: "$otherthread:localhost" },
},
},
},
};
syncData.rooms.join[roomOne].ephemeral.events = [receipt];
httpBackend!.when("GET", "/sync").respond(200, syncData);
client!.startClient();
return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent()]).then(() => {
const room = client?.getRoom(roomOne);
expect(room).toBeInstanceOf(Room);
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true);
const thread = room!.createThread(THREAD_ID, undefined, [], true);
expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false);
const receipt = thread.getReadReceiptForUserId("@alice:localhost");
expect(receipt).toStrictEqual({
data: {
thread_id: "$unknownthread:localhost",
ts: 666,
},
eventId: "$event1:localhost",
});
});
});
});
describe("of a room", () => {

View File

@@ -1590,4 +1590,92 @@ describe("megolm", () => {
aliceTestClient.httpBackend.flush("/send/m.room.encrypted/", 1),
]);
});
describe("Lazy-loading member lists", () => {
let p2pSession: Olm.Session;
beforeEach(async () => {
// set up the aliceTestClient so that it is a room with no known members
aliceTestClient.expectKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await aliceTestClient.start({ lazyLoadMembers: true });
aliceTestClient.client.setGlobalErrorOnUnknownDevices(false);
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, getSyncResponse([]));
await aliceTestClient.flushSync();
p2pSession = await establishOlmSession(aliceTestClient, testOlmAccount);
});
async function expectMembershipRequest(roomId: string, members: string[]): Promise<void> {
const membersPath = `/rooms/${encodeURIComponent(roomId)}/members?not_membership=leave`;
aliceTestClient.httpBackend.when("GET", membersPath).respond(200, {
chunk: [
testUtils.mkMembershipCustom({
membership: "join",
sender: "@bob:xyz",
}),
],
});
await aliceTestClient.httpBackend.flush(membersPath, 1);
}
it("Sending an event initiates a member list sync", async () => {
// we expect a call to the /members list...
const memberListPromise = expectMembershipRequest(ROOM_ID, ["@bob:xyz"]);
// then a request for bob's devices...
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
// then a to-device with the room_key
const inboundGroupSessionPromise = expectSendRoomKey(
aliceTestClient.httpBackend,
"@bob:xyz",
testOlmAccount,
p2pSession,
);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(
aliceTestClient.httpBackend,
inboundGroupSessionPromise,
);
// kick it off
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
await Promise.all([
sendPromise,
megolmMessagePromise,
memberListPromise,
aliceTestClient.httpBackend.flush("/keys/query", 1),
]);
});
it("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"])]);
// expect a request for bob's devices...
aliceTestClient.httpBackend.when("POST", "/keys/query").respond(200, getTestKeysQueryResponse("@bob:xyz"));
// then a to-device with the room_key
const inboundGroupSessionPromise = expectSendRoomKey(
aliceTestClient.httpBackend,
"@bob:xyz",
testOlmAccount,
p2pSession,
);
// and finally the megolm message
const megolmMessagePromise = expectSendMegolmMessage(
aliceTestClient.httpBackend,
inboundGroupSessionPromise,
);
// kick it off
const sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, "test");
await Promise.all([sendPromise, megolmMessagePromise, aliceTestClient.httpBackend.flush("/keys/query", 1)]);
});
});
});

View File

@@ -38,7 +38,7 @@ import {
IRoomTimelineData,
} from "../../src";
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
import { SyncState } from "../../src/sync";
import { SyncApiOptions, SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client";
import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils";
@@ -111,6 +111,7 @@ describe("SlidingSyncSdk", () => {
// assign client/httpBackend globals
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
testOpts = testOpts || {};
const syncOpts: SyncApiOptions = {};
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
@@ -118,10 +119,10 @@ describe("SlidingSyncSdk", () => {
if (testOpts.withCrypto) {
httpBackend!.when("GET", "/room_keys/version").respond(404, {});
await client!.initCrypto();
testOpts.crypto = client!.crypto;
syncOpts.cryptoCallbacks = syncOpts.crypto = client!.crypto;
}
httpBackend!.when("GET", "/_matrix/client/r0/pushrules").respond(200, {});
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts);
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
};
// tear down client/httpBackend globals

View File

@@ -1418,6 +1418,102 @@ describe("SlidingSync", () => {
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("should not be possible to add/modify an already added custom subscription", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName1, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("should change the custom subscription if they are different", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// using the same subscription doesn't unsub nor changes subscriptions
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeUndefined();
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// Changing the subscription works
slidingSync.useCustomSubscription(roomA, customSubName2);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!
.when("POST", syncUrl)
.check(function (req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub2);
expect(body.unsubscribe_rooms).toBeUndefined();
})
.respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
});
describe("extensions", () => {

View File

@@ -513,9 +513,6 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
public sendMetadataUpdate = jest.fn<void, []>();
public on = jest.fn();
public removeListener = jest.fn();
public getOpponentMember(): Partial<RoomMember> {
return this.opponentMember;
}

View File

@@ -544,7 +544,7 @@ describe("AutoDiscovery", function () {
.respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {});
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
@@ -591,7 +591,7 @@ describe("AutoDiscovery", function () {
.respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {});
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
@@ -636,9 +636,9 @@ describe("AutoDiscovery", function () {
versions: ["r0.0.1"],
});
httpBackend
.when("GET", "/_matrix/identity/api/v1")
.when("GET", "/_matrix/identity/v2")
.check((req) => {
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1");
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
})
.respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -682,9 +682,9 @@ describe("AutoDiscovery", function () {
versions: ["r0.0.1"],
});
httpBackend
.when("GET", "/_matrix/identity/api/v1")
.when("GET", "/_matrix/identity/v2")
.check((req) => {
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/api/v1");
expect(req.path).toEqual("https://identity.example.org/_matrix/identity/v2");
})
.respond(200, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {

View File

@@ -167,6 +167,38 @@ describe("Crypto", function () {
client.stopClient();
});
it("doesn't throw an error when attempting to decrypt a redacted event", async () => {
const client = new TestClient("@alice:example.com", "deviceid").client;
await client.initCrypto();
const event = new MatrixEvent({
content: {},
event_id: "$event_id",
room_id: "!room_id",
sender: "@bob:example.com",
type: "m.room.encrypted",
unsigned: {
redacted_because: {
content: {},
event_id: "$redaction_event_id",
redacts: "$event_id",
room_id: "!room_id",
origin_server_ts: 1234567890,
sender: "@bob:example.com",
type: "m.room.redaction",
unsigned: {},
},
},
});
await event.attemptDecryption(client.crypto!);
expect(event.isDecryptionFailure()).toBeFalsy();
// since the redaction event isn't encrypted, the redacted_because
// should be the same as in the original event
expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because);
client.stopClient();
});
});
describe("Session management", function () {

View File

@@ -1,56 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "../../../olm-loader";
import { CRYPTO_ENABLED, MatrixClient } from "../../../../src/client";
import { TestClient } from "../../../TestClient";
const Olm = global.Olm;
describe("crypto.setDeviceVerification", () => {
const userId = "@alice:example.com";
const deviceId1 = "device1";
let client: MatrixClient;
if (!CRYPTO_ENABLED) {
return;
}
beforeAll(async () => {
await Olm.init();
});
beforeEach(async () => {
client = new TestClient(userId, deviceId1).client;
await client.initCrypto();
});
it("client should provide crypto", () => {
expect(client.crypto).not.toBeUndefined();
});
describe("when setting an own device as verified", () => {
beforeEach(async () => {
jest.spyOn(client.crypto!, "cancelAndResendAllOutgoingKeyRequests");
await client.crypto!.setDeviceVerification(userId, deviceId1, true);
});
it("cancelAndResendAllOutgoingKeyRequests should be called", () => {
expect(client.crypto!.cancelAndResendAllOutgoingKeyRequests).toHaveBeenCalled();
});
});
});

View File

@@ -24,10 +24,13 @@ import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
RelationType,
Room,
RoomEvent,
} from "../../src";
import { Thread } from "../../src/models/thread";
import { FeatureSupport, Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
import { eventMapperFor } from "../../src/event-mapper";
describe("EventTimelineSet", () => {
const roomId = "!foo:bar";
@@ -202,6 +205,88 @@ describe("EventTimelineSet", () => {
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
Thread.hasServerSideSupport = FeatureSupport.Stable;
const sender = "@alice:matrix.org";
const root = utils.mkEvent({
event: true,
content: {
body: "Thread root",
},
type: EventType.RoomMessage,
sender,
});
room.addLiveEvents([root]);
const threadReply = utils.mkEvent({
event: true,
content: {
"body": "Thread reply",
"m.relates_to": {
event_id: root.getId()!,
rel_type: RelationType.Thread,
},
},
type: EventType.RoomMessage,
sender,
});
root.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
count: 1,
latest_event: {
content: threadReply.getContent(),
origin_server_ts: 5,
room_id: room.roomId,
sender,
type: EventType.RoomMessage,
event_id: threadReply.getId()!,
user_id: sender,
age: 1,
},
current_user_participated: true,
},
},
});
const editToThreadReply = utils.mkEvent({
event: true,
content: {
"body": " * edit",
"m.new_content": {
"body": "edit",
"msgtype": "m.text",
"org.matrix.msc1767.text": "edit",
},
"m.relates_to": {
event_id: threadReply.getId()!,
rel_type: RelationType.Replace,
},
},
type: EventType.RoomMessage,
sender,
});
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
return true;
});
jest.spyOn(client, "relations").mockResolvedValue({
events: [],
});
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
thread.once(RoomEvent.TimelineReset, () => {
const lastEvent = thread.timeline.at(-1)!;
expect(lastEvent.getContent().body).toBe(" * edit");
});
});
describe("non-room timeline", () => {
it("Adds event to timeline", () => {
const nonRoomEventTimelineSet = new EventTimelineSet(

View File

@@ -22,11 +22,13 @@ import { Filter } from "../../src/filter";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
import {
EventType,
RelationType,
RoomCreateTypeField,
RoomType,
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
MSC3912_RELATION_BASED_REDACTIONS_PROP,
} from "../../src/@types/event";
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { Crypto } from "../../src/crypto";
@@ -170,6 +172,10 @@ describe("MatrixClient", function () {
data: SYNC_DATA,
};
const unstableFeatures: Record<string, boolean> = {
"org.matrix.msc3440.stable": true,
};
// items are popped off when processed and block if no items left.
let httpLookups: HttpLookup[] = [];
let acceptKeepalives: boolean;
@@ -188,9 +194,7 @@ describe("MatrixClient", function () {
const { prefix } = requestOpts;
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
return Promise.resolve({
unstable_features: {
"org.matrix.msc3440.stable": true,
},
unstable_features: unstableFeatures,
versions: ["r0.6.0", "r0.6.1"],
});
}
@@ -1314,6 +1318,59 @@ describe("MatrixClient", function () {
await client.redactEvent(roomId, eventId, txnId, { reason });
});
describe("when calling with with_relations", () => {
const eventId = "$event42:example.org";
it("should raise an error if server has no support for relation based redactions", async () => {
// load supported features
await client.getVersions();
const txnId = client.makeTxnId();
expect(() => {
client.redactEvent(roomId, eventId, txnId, {
with_relations: [RelationType.Reference],
});
}).toThrowError(
new Error(
"Server does not support relation based redactions " +
`roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId null`,
),
);
});
describe("and the server supports relation based redactions (unstable)", () => {
beforeEach(async () => {
unstableFeatures["org.matrix.msc3912"] = true;
// load supported features
await client.getVersions();
});
it("should send with_relations in the request body", async () => {
const txnId = client.makeTxnId();
httpLookups = [
{
method: "PUT",
path:
`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}` +
`/${encodeURIComponent(txnId)}`,
expectBody: {
reason: "redaction test",
[MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: [RelationType.Reference],
},
data: { event_id: eventId },
},
];
await client.redactEvent(roomId, eventId, txnId, {
reason: "redaction test",
with_relations: [RelationType.Reference],
});
});
});
});
});
describe("cancelPendingEvent", () => {

View File

@@ -16,7 +16,6 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import { EventType } from "../../../src";
import { Crypto } from "../../../src/crypto";
describe("MatrixEvent", () => {
@@ -88,22 +87,6 @@ describe("MatrixEvent", () => {
expect(ev.getWireContent().ciphertext).toBeUndefined();
});
it("should abort decryption if fails with an error other than a DecryptionError", async () => {
const ev = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
content: {
body: "Test",
},
event_id: "$event1:server",
});
await ev.attemptDecryption({
decryptEvent: jest.fn().mockRejectedValue(new Error("Not a DecryptionError")),
} as unknown as Crypto);
expect(ev.isEncrypted()).toBeTruthy();
expect(ev.isBeingDecrypted()).toBeFalsy();
expect(ev.isDecryptionFailure()).toBeFalsy();
});
describe("applyVisibilityEvent", () => {
it("should emit VisibilityChange if a change was made", async () => {
const ev = new MatrixEvent({
@@ -134,6 +117,21 @@ describe("MatrixEvent", () => {
});
});
it("should report decryption errors", async () => {
const crypto = {
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
} as unknown as Crypto;
await encryptedEvent.attemptDecryption(crypto);
expect(encryptedEvent.isEncrypted()).toBeTruthy();
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
expect(encryptedEvent.getContent()).toEqual({
msgtype: "m.bad.encrypted",
body: "** Unable to decrypt: Error: test error **",
});
});
it("should retry decryption if a retry is queued", async () => {
const eventAttemptDecryptionSpy = jest.spyOn(encryptedEvent, "attemptDecryption");

View File

@@ -14,13 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_POLL_START } from "matrix-events-sdk";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import { Relations } from "../../src/models/relations";
import { Relations, RelationsEvent } from "../../src/models/relations";
import { TestClient } from "../TestClient";
import { RelationType } from "../../src";
import { logger } from "../../src/logger";
describe("Relations", function () {
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
it("should deduplicate annotations", function () {
const room = new Room("room123", null!, null!);
const relations = new Relations("m.annotation", "m.reaction", room);
@@ -75,6 +83,92 @@ describe("Relations", function () {
}
});
describe("addEvent()", () => {
const relationType = RelationType.Reference;
const eventType = M_POLL_START.stable!;
const altEventTypes = [M_POLL_START.unstable!];
const room = new Room("room123", null!, null!);
it("should not add events without a relation", async () => {
// dont pollute console
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({ type: eventType });
await relations.addEvent(event);
expect(logSpy).toHaveBeenCalledWith("Event must have relation info");
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
it("should not add events of incorrect event type", async () => {
// dont pollute console
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({
type: "different-event-type",
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: relationType,
},
},
});
await relations.addEvent(event);
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
it("adds events that match alt event types", async () => {
const relations = new Relations(relationType, eventType, room, altEventTypes);
const emitSpy = jest.spyOn(relations, "emit");
const event = new MatrixEvent({
type: M_POLL_START.unstable!,
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: relationType,
},
},
});
await relations.addEvent(event);
// event added
expect(relations.getRelations()).toEqual([event]);
expect(emitSpy).toHaveBeenCalledWith(RelationsEvent.Add, event);
});
it("should not add events of incorrect relation type", async () => {
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const relations = new Relations(relationType, eventType, room);
const event = new MatrixEvent({
type: eventType,
content: {
"m.relates_to": {
event_id: "$2s4yYpEkVQrPglSCSqB_m6E8vDhWsg0yFNyOJdVIb_o",
rel_type: "m.annotation",
},
},
});
await relations.addEvent(event);
const emitSpy = jest.spyOn(relations, "emit");
expect(logSpy).toHaveBeenCalledWith(`Event relation info doesn't match this container`);
// event not added
expect(relations.getRelations().length).toBe(0);
expect(emitSpy).not.toHaveBeenCalled();
});
});
it("should emit created regardless of ordering", async function () {
const targetEvent = new MatrixEvent({
sender: "@bob:example.com",

View File

@@ -0,0 +1,248 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import {
KeysBackupRequest,
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
OlmMachine,
SignatureUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { Mocked } from "jest-mock";
import MockHttpBackend from "matrix-mock-request";
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";
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();
});
describe("RustCrypto", () => {
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe(".exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should return a list", async () => {
const keys = await rustCrypto.exportRoomKeys();
expect(Array.isArray(keys)).toBeTruthy();
});
});
describe("to-device messages", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
});
it("should pass through unencrypted to-device messages", async () => {
const inputs: IToDeviceEvent[] = [
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
it("should pass through bad encrypted messages", async () => {
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const keys = olmMachine.identityKeys;
const inputs: IToDeviceEvent[] = [
{
type: "m.room.encrypted",
content: {
algorithm: "m.olm.v1.curve25519-aes-sha2",
sender_key: "IlRMeOPX2e0MurIyfWEucYBRVOEEUMrOHqn/8mLqMjA",
ciphertext: {
[keys.curve25519.toBase64()]: {
type: 0,
body: "ajyjlghi",
},
},
},
sender: "@alice:example.com",
},
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
});
});
describe("outgoing requests", () => {
/** the RustCrypto implementation under test */
let rustCrypto: RustCrypto;
/** A mock http backend which rustCrypto is connected to */
let httpBackend: MockHttpBackend;
/** a mocked-up OlmMachine which rustCrypto is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
/** A list of results to be returned from olmMachine.outgoingRequest. Each call will shift a result off
* the front of the queue, until it is empty. */
let outgoingRequestQueue: Array<Array<any>>;
/** wait for a call to olmMachine.markRequestAsSent */
function awaitCallToMarkAsSent(): Promise<void> {
return new Promise((resolve, _reject) => {
olmMachine.markRequestAsSent.mockImplementationOnce(async () => {
resolve(undefined);
});
});
}
beforeEach(async () => {
httpBackend = new MockHttpBackend();
await RustSdkCryptoJs.initAsync();
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
baseUrl: "https://example.com",
prefix: "/_matrix",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
onlyData: true,
});
// for these tests we use a mock OlmMachine, with an implementation of outgoingRequests that
// returns objects from outgoingRequestQueue
outgoingRequestQueue = [];
olmMachine = {
outgoingRequests: jest.fn().mockImplementation(() => {
return Promise.resolve(outgoingRequestQueue.shift() ?? []);
}),
markRequestAsSent: jest.fn(),
close: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(olmMachine, httpApi, TEST_USER, TEST_DEVICE_ID);
});
it("should poll for outgoing messages", () => {
rustCrypto.onSyncCompleted({});
expect(olmMachine.outgoingRequests).toHaveBeenCalled();
});
/* simple requests that map directly to the request body */
const tests: Array<[any, "POST" | "PUT", string]> = [
[KeysUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/upload"],
[KeysQueryRequest, "POST", "https://example.com/_matrix/client/v3/keys/query"],
[KeysClaimRequest, "POST", "https://example.com/_matrix/client/v3/keys/claim"],
[SignatureUploadRequest, "POST", "https://example.com/_matrix/client/v3/keys/signatures/upload"],
[KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
];
for (const [RequestClass, expectedMethod, expectedPath] of tests) {
it(`should handle ${RequestClass.name}s`, async () => {
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
outgoingRequestQueue.push([outgoingRequest]);
const testResponse = '{ "result": 1 }';
httpBackend
.when(expectedMethod, "/_matrix")
.check((req) => {
expect(req.path).toEqual(expectedPath);
expect(req.rawData).toEqual(testBody);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
rustCrypto.onSyncCompleted({});
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1);
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await markSentCallPromise;
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
}
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
outgoingRequestQueue.push([outgoingRequest]);
rustCrypto.onSyncCompleted({});
await awaitCallToMarkAsSent();
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("5678", 987, "");
});
it("stops looping when stop() is called", async () => {
const testResponse = '{ "result": 1 }';
for (let i = 0; i < 5; i++) {
outgoingRequestQueue.push([new KeysQueryRequest("1234", "{}")]);
httpBackend.when("POST", "/_matrix").respond(200, testResponse, true);
}
rustCrypto.onSyncCompleted({});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeTruthy();
// go a couple of times round the loop
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
// a second sync while this is going on shouldn't make any difference
rustCrypto.onSyncCompleted({});
await httpBackend.flush("/_matrix", 1);
await awaitCallToMarkAsSent();
// now stop...
rustCrypto.stop();
// which should (eventually) cause the loop to stop with no further calls to outgoingRequests
olmMachine.outgoingRequests.mockReset();
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(rustCrypto["outgoingRequestLoopRunning"]).toBeFalsy();
httpBackend.verifyNoOutstandingRequests();
expect(olmMachine.outgoingRequests).not.toHaveBeenCalled();
// we sent three, so there should be 2 left
expect(outgoingRequestQueue.length).toEqual(2);
});
});
});

View File

@@ -142,6 +142,15 @@ describe("Group Call", function () {
} as unknown as RoomMember;
});
it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))(
"throws when initializing local call feed in %s state",
async (state: GroupCallState) => {
// @ts-ignore
groupCall.state = state;
await expect(groupCall.initLocalCallFeed()).rejects.toThrowError();
},
);
it("does not initialize local call feed, if it already is", async () => {
await groupCall.initLocalCallFeed();
jest.spyOn(groupCall, "initLocalCallFeed");
@@ -308,6 +317,17 @@ describe("Group Call", function () {
}
});
describe("hasLocalParticipant()", () => {
it("should return false, if we don't have a local participant", () => {
expect(groupCall.hasLocalParticipant()).toBeFalsy();
});
it("should return true, if we do have local participant", async () => {
await groupCall.enter();
expect(groupCall.hasLocalParticipant()).toBeTruthy();
});
});
describe("call feeds changing", () => {
let call: MockMatrixCall;
const currentFeed = new MockCallFeed(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, new MockMediaStream("current"));
@@ -475,7 +495,7 @@ describe("Group Call", function () {
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
// @ts-ignore
groupCall.calls.set(
mockCall.getOpponentMember() as RoomMember,
mockCall.getOpponentMember().userId!,
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
);
@@ -501,7 +521,7 @@ describe("Group Call", function () {
const mockCall = new MockMatrixCall(FAKE_ROOM_ID, groupCall.groupCallId);
// @ts-ignore
groupCall.calls.set(
mockCall.getOpponentMember() as RoomMember,
mockCall.getOpponentMember().userId!,
new Map([[mockCall.getOpponentDeviceId()!, mockCall.typed()]]),
);
@@ -663,9 +683,7 @@ describe("Group Call", function () {
expect(client1.sendToDevice).toHaveBeenCalled();
// @ts-ignore
const oldCall = groupCall1.calls
.get(groupCall1.room.getMember(client2.userId)!)!
.get(client2.deviceId)!;
const oldCall = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
oldCall.emit(CallEvent.Hangup, oldCall!);
client1.sendToDevice.mockClear();
@@ -685,9 +703,7 @@ describe("Group Call", function () {
let newCall: MatrixCall | undefined;
while (
// @ts-ignore
(newCall = groupCall1.calls
.get(groupCall1.room.getMember(client2.userId)!)
?.get(client2.deviceId)) === undefined ||
(newCall = groupCall1.calls.get(client2.userId)?.get(client2.deviceId)) === undefined ||
newCall.peerConn === undefined ||
newCall.callId == oldCall.callId
) {
@@ -730,7 +746,7 @@ describe("Group Call", function () {
groupCall1.setLocalVideoMuted(false);
// @ts-ignore
const call = groupCall1.calls.get(groupCall1.room.getMember(client2.userId)!)!.get(client2.deviceId)!;
const call = groupCall1.calls.get(client2.userId)!.get(client2.deviceId)!;
call.isMicrophoneMuted = jest.fn().mockReturnValue(true);
call.setMicrophoneMuted = jest.fn();
call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
@@ -839,7 +855,7 @@ describe("Group Call", function () {
await sleep(10);
// @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
// @ts-ignore Mock
call.pushRemoteFeed(
@@ -866,7 +882,7 @@ describe("Group Call", function () {
await sleep(10);
// @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
// @ts-ignore Mock
call.pushRemoteFeed(
@@ -943,9 +959,7 @@ describe("Group Call", function () {
expect(mockCall.reject).not.toHaveBeenCalled();
expect(mockCall.answerWithCallFeeds).toHaveBeenCalled();
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]),
);
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, mockCall]])]]));
});
it("replaces calls if it already has one with the same user", async () => {
@@ -960,9 +974,7 @@ describe("Group Call", function () {
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(newMockCall.answerWithCallFeeds).toHaveBeenCalled();
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[groupCall.room.getMember(FAKE_USER_ID_1)!, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]),
);
expect(groupCall.calls).toEqual(new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall]])]]));
});
it("starts to process incoming calls when we've entered", async () => {
@@ -975,6 +987,83 @@ describe("Group Call", function () {
expect(call.answerWithCallFeeds).toHaveBeenCalled();
});
describe("handles call being replaced", () => {
let callChangedListener: jest.Mock;
let oldMockCall: MockMatrixCall;
let newMockCall: MockMatrixCall;
let newCallsMap: Map<string, Map<string, MatrixCall>>;
beforeEach(() => {
callChangedListener = jest.fn();
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
oldMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
newMockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
newCallsMap = new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_1, newMockCall.typed()]])]]);
newMockCall.opponentMember = oldMockCall.opponentMember; // Ensure referential equality
newMockCall.callId = "not " + oldMockCall.callId;
mockClient.emit(CallEventHandlerEvent.Incoming, oldMockCall.typed());
});
it("handles regular case", () => {
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
// @ts-ignore
expect(groupCall.calls).toEqual(newCallsMap);
});
it("handles case where call is missing from the calls map", () => {
// @ts-ignore
groupCall.calls = new Map();
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
// @ts-ignore
expect(groupCall.calls).toEqual(newCallsMap);
});
});
describe("handles call being hangup", () => {
let callChangedListener: jest.Mock;
let mockCall: MockMatrixCall;
beforeEach(() => {
callChangedListener = jest.fn();
groupCall.addListener(GroupCallEvent.CallsChanged, callChangedListener);
mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId);
});
it("doesn't throw when calls map is empty", () => {
// @ts-ignore
expect(() => groupCall.onCallHangup(mockCall)).not.toThrow();
});
it("clears map completely when we're the last users device left", () => {
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
mockCall.emit(CallEvent.Hangup, mockCall.typed());
// @ts-ignore
expect(groupCall.calls).toEqual(new Map());
});
it("doesn't remove another call of the same user", () => {
const anotherCallOfTheSameUser = new MockMatrixCall(room.roomId, groupCall.groupCallId);
anotherCallOfTheSameUser.callId = "another call id";
anotherCallOfTheSameUser.getOpponentDeviceId = () => FAKE_DEVICE_ID_2;
mockClient.emit(CallEventHandlerEvent.Incoming, anotherCallOfTheSameUser.typed());
mockClient.emit(CallEventHandlerEvent.Incoming, mockCall.typed());
mockCall.emit(CallEvent.Hangup, mockCall.typed());
// @ts-ignore
expect(groupCall.calls).toEqual(
new Map([[FAKE_USER_ID_1, new Map([[FAKE_DEVICE_ID_2, anotherCallOfTheSameUser.typed()]])]]),
);
});
});
});
describe("screensharing", () => {
@@ -1039,7 +1128,7 @@ describe("Group Call", function () {
await sleep(10);
// @ts-ignore
const call = groupCall.calls.get(groupCall.room.getMember(FAKE_USER_ID_2)!)!.get(FAKE_DEVICE_ID_2)!;
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.onNegotiateReceived({
getContent: () => ({