mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
callParticipation -> rtcParticipationManager driverCapabilites -> MatrixClientFeatures
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
/*
|
|
Copyright 2026 Element Creations Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
/**
|
|
* Full integration against the dev backend (`pnpm backend`: Synapse develop,
|
|
* lk-jwt-service and LiveKit behind nginx with the dev TLS certificate)
|
|
* through the js-sdk drivers: two real users in one encrypted room, two
|
|
* participations, and every wire feature the crate relies on — transport
|
|
* discovery, the slot state event, sticky member events, delayed events and
|
|
* their delegation, the token exchange, Olm-encrypted media keys and
|
|
* homeserver connectivity.
|
|
*
|
|
* Opt-in, because it needs the backend and takes a minute:
|
|
*
|
|
* MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
|
* pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts
|
|
*
|
|
* `HOMESERVER_URL` overrides the homeserver (default: the dev backend).
|
|
*/
|
|
|
|
// The global `process` is vite-plugin-node-polyfills' browser shim, whose
|
|
// `env` is empty; the real one comes from the module.
|
|
import { env } from "node:process";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
ClientEvent,
|
|
createClient,
|
|
type MatrixClient,
|
|
Method,
|
|
Preset,
|
|
type Room,
|
|
SyncState,
|
|
} from "matrix-js-sdk";
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
|
|
|
import { MatrixRTCMode } from "../../config/ConfigOptions";
|
|
import { JsSdkElementCallMatrixClientDriver } from "../../driver/jsSdk/JsSdkElementCallMatrixClientDriver";
|
|
import { JsSdkRtcMatrixDriver } from "../../driver/jsSdk/JsSdkRtcMatrixDriver";
|
|
import { waitFor } from "../../driver/MockRtcMatrixDriver";
|
|
import {
|
|
FfiDelegationRoute,
|
|
FfiImpairment,
|
|
FfiKeepAlive,
|
|
FfiStatus,
|
|
} from "../../matrix-rtc-sdk";
|
|
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
|
|
import { testScope } from "../../utils/test";
|
|
import { RtcParticipationManager } from "./RtcParticipationManager";
|
|
import { joinParamsFromConfig, participationConfig } from "./joinParams";
|
|
import { ELEMENT_CALL_SLOT_EVENT_TYPE, ELEMENT_CALL_SLOT_ID } from "./slot";
|
|
import { publishOnLivekit } from "./transportIntent";
|
|
|
|
const enabled = env.MATRIX_RTC_BACKEND === "1";
|
|
const HOMESERVER_URL = (
|
|
env.HOMESERVER_URL ?? "https://synapse.m.localhost"
|
|
).replace(/\/$/, "");
|
|
/** The transport `backend/dev_homeserver.yaml` advertises. */
|
|
const DEV_LIVEKIT_SERVICE_URL = "https://matrix-rtc.m.localhost/livekit/jwt";
|
|
const DELAYED_EVENTS_PREFIX = "/_matrix/client/unstable/org.matrix.msc4140";
|
|
|
|
const session = {
|
|
delayed_leave: { delay_ms: 18_000 },
|
|
delegated_delayed_leave: { delay_ms: 3_600_000 },
|
|
network_error_retry_ms: 1000,
|
|
wait_for_key_rotation_ms: 50,
|
|
};
|
|
|
|
interface TestUser {
|
|
name: string;
|
|
client: MatrixClient;
|
|
room: Room;
|
|
userId: string;
|
|
deviceId: string;
|
|
rtcDriver: JsSdkRtcMatrixDriver;
|
|
clientDriver: JsSdkElementCallMatrixClientDriver;
|
|
stop: () => void;
|
|
}
|
|
|
|
interface DelayedEvent {
|
|
delay_id: string;
|
|
room_id: string;
|
|
type: string;
|
|
state_key?: string;
|
|
delay: number;
|
|
running_since: number;
|
|
content: Record<string, unknown>;
|
|
}
|
|
|
|
const log = (who: string, line: string): void =>
|
|
// Progress of an opt-in integration run, meant to be read.
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[backend ${who}] ${line}`);
|
|
|
|
/**
|
|
* Register a throwaway user, boot rust crypto, sync, and be in the room.
|
|
* `legacyMembers` gives every member the power to send the MSC3401 member
|
|
* *state* event, as Element Call's own rooms do (`state_default: 0`); a
|
|
* plain room keeps the default 50, which also keeps Bob from opening a slot.
|
|
*/
|
|
async function createUser(
|
|
name: string,
|
|
roomId?: string,
|
|
{ encrypted = true, legacyMembers = false } = {},
|
|
): Promise<TestUser> {
|
|
const localpart = `ec-${name.toLowerCase()}-${Date.now().toString(16)}${Math.floor(
|
|
Math.random() * 0xffff,
|
|
).toString(16)}`;
|
|
const register = async (): Promise<Response> =>
|
|
fetch(`${HOMESERVER_URL}/_matrix/client/v3/register`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
username: localpart,
|
|
password: `test-${localpart}`,
|
|
auth: { type: "m.login.dummy" },
|
|
}),
|
|
});
|
|
let response = await register();
|
|
// Synapse rate-limits registrations; the second case of this file runs
|
|
// straight into that.
|
|
while (response.status === 429) {
|
|
const { retry_after_ms: retryAfterMs = 1000 } = (await response.json()) as {
|
|
retry_after_ms?: number;
|
|
};
|
|
await new Promise((resolve) => setTimeout(resolve, retryAfterMs + 100));
|
|
response = await register();
|
|
}
|
|
if (!response.ok)
|
|
throw new Error(
|
|
`registration failed: ${response.status} ${await response.text()}`,
|
|
);
|
|
const {
|
|
user_id: userId,
|
|
device_id: deviceId,
|
|
access_token: accessToken,
|
|
} = (await response.json()) as {
|
|
user_id: string;
|
|
device_id: string;
|
|
access_token: string;
|
|
};
|
|
const client = createClient({
|
|
baseUrl: HOMESERVER_URL,
|
|
accessToken,
|
|
userId,
|
|
deviceId,
|
|
logger: logger.getChild(`[${name}]`),
|
|
});
|
|
// In memory on purpose: every run is a fresh device.
|
|
await client.initRustCrypto({ useIndexedDB: false });
|
|
await client.setDisplayName(name);
|
|
void client.startClient();
|
|
await new Promise<void>((resolve, reject) => {
|
|
client.once(ClientEvent.Sync, (state) =>
|
|
state === SyncState.Prepared
|
|
? resolve()
|
|
: reject(new Error(`sync failed: ${state}`)),
|
|
);
|
|
});
|
|
|
|
if (roomId === undefined) {
|
|
const created = await client.createRoom({
|
|
preset: Preset.PublicChat,
|
|
name: `Element Call backend check ${new Date().toISOString()}`,
|
|
power_level_content_override: legacyMembers
|
|
? { events: { "org.matrix.msc3401.call.member": 0 } }
|
|
: undefined,
|
|
initial_state: encrypted
|
|
? [
|
|
{
|
|
type: "m.room.encryption",
|
|
state_key: "",
|
|
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
|
},
|
|
]
|
|
: [],
|
|
});
|
|
roomId = created.room_id;
|
|
} else {
|
|
await client.joinRoom(roomId);
|
|
}
|
|
await waitFor(
|
|
`${name}'s room to appear in sync`,
|
|
() => client.getRoom(roomId) !== null,
|
|
10_000,
|
|
);
|
|
const room = client.getRoom(roomId)!;
|
|
log(name, `ready as ${userId} (${deviceId}) in ${roomId}`);
|
|
|
|
const rtcDriver = new JsSdkRtcMatrixDriver(client, room);
|
|
const clientDriver = new JsSdkElementCallMatrixClientDriver(client, room);
|
|
return {
|
|
name,
|
|
client,
|
|
room,
|
|
userId,
|
|
deviceId,
|
|
rtcDriver,
|
|
clientDriver,
|
|
stop: () => {
|
|
rtcDriver.detach();
|
|
client.stopClient();
|
|
},
|
|
};
|
|
}
|
|
|
|
function participate(
|
|
user: TestUser,
|
|
mode: MatrixRTCMode,
|
|
manageMediaKeys: boolean,
|
|
): RtcParticipationManager {
|
|
return new RtcParticipationManager(
|
|
testScope(),
|
|
user.rtcDriver,
|
|
user.room.roomId,
|
|
user.userId,
|
|
user.deviceId,
|
|
{
|
|
config: participationConfig({ mode, manageMediaKeys, session }),
|
|
logger: logger.getChild(`[${user.name}]`),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function listDelayedEvents(
|
|
client: MatrixClient,
|
|
): Promise<DelayedEvent[]> {
|
|
const response = await client.http.authedRequest<{
|
|
delayed_events: DelayedEvent[];
|
|
}>(Method.Get, "/delayed_events", undefined, undefined, {
|
|
prefix: DELAYED_EVENTS_PREFIX,
|
|
});
|
|
return response.delayed_events;
|
|
}
|
|
|
|
async function fetchRawEvent(
|
|
client: MatrixClient,
|
|
roomId: string,
|
|
eventId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
return (await client.fetchRoomEvent(roomId, eventId)) as unknown as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
}
|
|
|
|
function connected(
|
|
rtcParticipationManager: RtcParticipationManager,
|
|
): InstanceType<typeof FfiStatus.Connected>["inner"] {
|
|
const status = rtcParticipationManager.status$.value;
|
|
if (!FfiStatus.Connected.instanceOf(status))
|
|
throw new Error(`Expected Connected, got ${status.tag}`);
|
|
return status.inner;
|
|
}
|
|
|
|
describe.skipIf(!enabled)(
|
|
"RtcParticipationManager against the dev backend",
|
|
() => {
|
|
beforeAll(async () => {
|
|
// The two clients' crypto debug output would drown everything else.
|
|
(logger as unknown as { setLevel(level: string): void }).setLevel("warn");
|
|
await initMatrixRtcSdkForTests();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
mode: MatrixRTCMode.Matrix_2_0,
|
|
memberEventType: "org.matrix.msc4143.rtc.member",
|
|
sticky: true,
|
|
},
|
|
{
|
|
mode: MatrixRTCMode.Compatibility,
|
|
memberEventType: "org.matrix.msc3401.call.member",
|
|
sticky: false,
|
|
},
|
|
])(
|
|
"two users call each other in $mode mode",
|
|
async ({ mode, memberEventType, sticky }) => {
|
|
const alice = await createUser("Alice", undefined, {
|
|
legacyMembers: !sticky,
|
|
});
|
|
const bob = await createUser("Bob", alice.room.roomId);
|
|
const roomId = alice.room.roomId;
|
|
const a = participate(alice, mode, true);
|
|
const b = participate(bob, mode, true);
|
|
const joinParams = joinParamsFromConfig({
|
|
session,
|
|
delegateDelayedLeave: true,
|
|
});
|
|
const dump = (): void => {
|
|
log("alice", `snapshot: ${a.debugSnapshot()}`);
|
|
log("bob", `snapshot: ${b.debugSnapshot()}`);
|
|
};
|
|
try {
|
|
// --- connectivity, before anything else ------------------------------
|
|
expect(alice.rtcDriver.isHomeserverConnected()).toBe(true);
|
|
|
|
// --- the slot ----------------------------------------------------------
|
|
// A fresh room has no slot. Alice created it, so she may open one; Bob
|
|
// (power level 0, state_default 50) may not.
|
|
await waitFor("the seed", () => a.session$.value.seeded, 15_000);
|
|
expect(a.session$.value.slotOpen).not.toBe(true);
|
|
expect(
|
|
alice.room.currentState.getStateEvents(
|
|
ELEMENT_CALL_SLOT_EVENT_TYPE,
|
|
),
|
|
).toEqual([]);
|
|
const aliceRoom = alice.clientDriver.getRoomInfo();
|
|
const bobRoom = bob.clientDriver.getRoomInfo();
|
|
expect(aliceRoom.encrypted).toBe(true);
|
|
expect(aliceRoom.canOpenSlot).toBe(true);
|
|
expect(bobRoom.canOpenSlot).toBe(false);
|
|
|
|
// --- Alice joins: opens the slot, discovers the transport, publishes --
|
|
await a.join(publishOnLivekit(), joinParams, {
|
|
encrypted: aliceRoom.encrypted,
|
|
canOpen: aliceRoom.canOpenSlot,
|
|
});
|
|
const aliceStatus = connected(a);
|
|
log("alice", `joined: keepAlive=${aliceStatus.keepAlive.tag}`);
|
|
|
|
const slot = alice.room.currentState.getStateEvents(
|
|
ELEMENT_CALL_SLOT_EVENT_TYPE,
|
|
ELEMENT_CALL_SLOT_ID,
|
|
);
|
|
if (sticky) {
|
|
expect(slot?.getContent()).toMatchObject({
|
|
status: "open",
|
|
application: { type: "m.call" },
|
|
encryption: { type: "m.per_member" },
|
|
});
|
|
await waitFor(
|
|
"bob to see the slot",
|
|
() => b.session$.value.slotOpen === true,
|
|
15_000,
|
|
);
|
|
} else {
|
|
// The pre-slot generation: nothing opened, nothing to wait for.
|
|
expect(slot).toBeNull();
|
|
}
|
|
|
|
// The transport came from the homeserver's /rtc/transports.
|
|
const [connection] = a.connections$.value;
|
|
expect(connection.connection.serviceUrl).toBe(
|
|
DEV_LIVEKIT_SERVICE_URL,
|
|
);
|
|
expect(connection.connection.jwtToken.split(".")).toHaveLength(3);
|
|
expect(connection.connection.wsUrl).toMatch(/^wss:\/\//);
|
|
|
|
// --- the member event on the wire -------------------------------------
|
|
await waitFor(
|
|
"alice to see her own membership",
|
|
() => a.ownMembership$.value !== null,
|
|
15_000,
|
|
);
|
|
const ownEventId = a.ownMembership$.value!.member.eventId;
|
|
expect(ownEventId).toBeDefined();
|
|
const raw = await fetchRawEvent(alice.client, roomId, ownEventId!);
|
|
log("alice", `own member event: ${JSON.stringify(raw)}`);
|
|
if (sticky) {
|
|
// A sticky event is a timeline event: in an encrypted room matrix-js-sdk
|
|
// Megolm-encrypts it like any other (its own MatrixRTC code decrypts
|
|
// them on the way in, as our driver does). The sticky marker is in
|
|
// the clear.
|
|
expect(raw.type).toBe("m.room.encrypted");
|
|
expect(raw.state_key).toBeUndefined();
|
|
expect(raw).toHaveProperty("msc4354_sticky");
|
|
const decrypted = [...alice.room._unstable_getStickyEvents()].find(
|
|
(e) => e.getId() === ownEventId,
|
|
);
|
|
expect(decrypted?.getType()).toBe(memberEventType);
|
|
} else {
|
|
expect(raw.type).toBe(memberEventType);
|
|
expect(raw.state_key).toBe(
|
|
`_${alice.userId}_${alice.deviceId}_m.call`,
|
|
);
|
|
}
|
|
|
|
// --- delayed events and their delegation -------------------------------
|
|
const delayed = await listDelayedEvents(alice.client);
|
|
log("alice", `delayed events: ${JSON.stringify(delayed)}`);
|
|
expect(delayed).toHaveLength(1);
|
|
expect(delayed[0].room_id).toBe(roomId);
|
|
expect(delayed[0].type).toBe(memberEventType);
|
|
const keepAlive = aliceStatus.keepAlive;
|
|
if (sticky) {
|
|
// The dev Synapse proxies `rtc/livekit/*` to lk-jwt-service
|
|
// (MSC4512), so the homeserver route takes the long leave over.
|
|
expect(FfiKeepAlive.Delegated.instanceOf(keepAlive)).toBe(true);
|
|
expect(
|
|
(keepAlive as InstanceType<typeof FfiKeepAlive.Delegated>).inner
|
|
.via,
|
|
).toBe(FfiDelegationRoute.Homeserver);
|
|
expect(delayed[0].delay).toBe(
|
|
session.delegated_delayed_leave.delay_ms,
|
|
);
|
|
} else {
|
|
// MSC4195 is not spoken for the pre-slot generation: our own leave.
|
|
expect(FfiKeepAlive.Armed.instanceOf(keepAlive)).toBe(true);
|
|
expect(delayed[0].delay).toBe(session.delayed_leave.delay_ms);
|
|
}
|
|
|
|
// --- Bob joins: roster, profiles, one connection with two members -----
|
|
await b.join(publishOnLivekit(), joinParams, {
|
|
encrypted: bobRoom.encrypted,
|
|
canOpen: bobRoom.canOpenSlot,
|
|
});
|
|
connected(b);
|
|
await waitFor(
|
|
"alice to see bob",
|
|
() =>
|
|
a.memberships$.value.value.some(
|
|
(m) => m.member.userId === bob.userId,
|
|
),
|
|
20_000,
|
|
);
|
|
await waitFor(
|
|
"bob to see alice",
|
|
() =>
|
|
b.memberships$.value.value.some(
|
|
(m) => m.member.userId === alice.userId,
|
|
),
|
|
20_000,
|
|
);
|
|
const bobSeenByAlice = a.memberships$.value.value.find(
|
|
(m) => m.member.userId === bob.userId,
|
|
)!;
|
|
expect(bobSeenByAlice.member.displayName).toBe("Bob");
|
|
expect(bobSeenByAlice.member.deviceId).toBe(bob.deviceId);
|
|
expect(bobSeenByAlice.connections).toEqual([DEV_LIVEKIT_SERVICE_URL]);
|
|
await waitFor(
|
|
"two members on alice's connection",
|
|
() => a.connections$.value[0]?.members.length === 2,
|
|
20_000,
|
|
);
|
|
|
|
// --- media keys, Olm-encrypted to-device both ways ---------------------
|
|
await waitFor(
|
|
"bob to hold alice's key",
|
|
() =>
|
|
b.keyMap$.value.some((k) => k.memberId === a.ownMemberId$.value),
|
|
30_000,
|
|
);
|
|
await waitFor(
|
|
"alice to hold bob's key",
|
|
() =>
|
|
a.keyMap$.value.some((k) => k.memberId === b.ownMemberId$.value),
|
|
30_000,
|
|
);
|
|
await waitFor(
|
|
"alice to know bob holds her key",
|
|
() =>
|
|
a.memberships$.value.value.find(
|
|
(m) => m.member.userId === bob.userId,
|
|
)?.mediaKey?.holdsOurKey === true,
|
|
30_000,
|
|
);
|
|
const bobKeyState = a.memberships$.value.value.find(
|
|
(m) => m.member.userId === bob.userId,
|
|
)!.mediaKey!;
|
|
log("alice", `bob's key state: ${JSON.stringify(bobKeyState)}`);
|
|
expect(bobKeyState.haveTheirKey).toBe(true);
|
|
expect(bobKeyState.rejection).toBeUndefined();
|
|
// MSC4153 verdict travels with the key (C10): nobody here is
|
|
// cross-signed, so the answer is "no", not "unknown".
|
|
expect(bobKeyState.senderCrossSigned).toBe(false);
|
|
|
|
// --- Alice leaves: delayed events cancelled, Bob sees her go -----------
|
|
await a.leave("m.user_hangup");
|
|
expect(FfiStatus.Disconnected.instanceOf(a.status$.value)).toBe(true);
|
|
expect(await listDelayedEvents(alice.client)).toEqual([]);
|
|
await waitFor(
|
|
"bob to see alice gone",
|
|
() =>
|
|
!b.memberships$.value.value.some(
|
|
(m) => m.member.userId === alice.userId,
|
|
),
|
|
20_000,
|
|
);
|
|
|
|
// --- losing the homeserver is a critical impairment (C12) -------------
|
|
// Bob's network goes away: every request fails and the long-poll in
|
|
// flight is cut, so matrix-js-sdk's sync loop leaves `Syncing`.
|
|
const { opts } = bob.client.http;
|
|
opts.fetchFn = async () => {
|
|
return Promise.reject(new TypeError("network down"));
|
|
};
|
|
bob.client.http.abort();
|
|
const unreachable = (): boolean =>
|
|
connected(b).impairments.some((i) =>
|
|
FfiImpairment.HomeserverUnreachable.instanceOf(i),
|
|
);
|
|
await waitFor(
|
|
"bob's participation to notice the homeserver is gone",
|
|
unreachable,
|
|
15_000,
|
|
);
|
|
// ...and comes back: the sync loop recovers and the impairment clears.
|
|
delete opts.fetchFn;
|
|
await waitFor(
|
|
"bob's participation to see the homeserver again",
|
|
() => !unreachable(),
|
|
30_000,
|
|
);
|
|
await b.leave();
|
|
expect(FfiStatus.Disconnected.instanceOf(b.status$.value)).toBe(true);
|
|
} catch (e) {
|
|
dump();
|
|
throw e;
|
|
} finally {
|
|
// Leave before the clients stop: a leave needs the homeserver.
|
|
await a.leave();
|
|
await b.leave();
|
|
alice.stop();
|
|
bob.stop();
|
|
}
|
|
},
|
|
180_000,
|
|
);
|
|
},
|
|
);
|