Files
matrix-js-sdk/spec/unit/matrixrtc/CallMembership.spec.ts
fkwp 159cca0363 Adapt LiveKit Identity hash calculation to latest MSC4195 update (#5268)
* adapt hash calculation to latest MSC4195 update.

Stop using '|' delimiters in hashes; use JSON arrays + canonical JSON instead

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* Update the test for RTC backend identities and add tests for calculating the identity hash.

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* linting

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* add copyright header

---------

Signed-off-by: fkwp <github-fkwp@w4ve.de>
2026-04-16 09:48:11 +00:00

434 lines
19 KiB
TypeScript

/*
Copyright 2023-2026 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 { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
import { type IContent, type MatrixEvent } from "../../../src/models/event.ts";
import { EventType } from "../../../src/@types/event.ts";
import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership.ts";
function createCallMembership(ev: MatrixEvent, content: IContent): CallMembership {
vi.mocked(ev.getContent).mockReturnValue(content);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, "xx");
}
describe("CallMembership", () => {
describe("SessionMembershipData", () => {
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
} as unknown as MatrixEvent;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const membershipTemplate: SessionMembershipData = {
"call_id": "",
"scope": "m.room",
"application": "m.call",
"device_id": "AAAAAAA",
"focus_active": { type: "livekit", focus_selection: "oldest_membership" },
"foci_preferred": [{ type: "livekit" }],
"m.call.intent": "voice",
};
it("rejects membership with no device_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("allow membership with no scope", () => {
expect(() => {
createCallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = createCallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("uses created_ts if present", () => {
const membership = createCallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
expect(membership.createdTs()).toEqual(67890);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getTs = vi.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1));
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
});
it("considers memberships expired if local age large enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getTs = vi.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1));
expect(createCallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
});
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = createCallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] });
expect(membership.transports).toEqual([mockFocus]);
});
describe("getTransport", () => {
const mockFocus = { type: "this_is_a_mock_focus" };
const oldestMembership = createCallMembership(makeMockEvent(), membershipTemplate);
it("gets the correct active transport with oldest_membership", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we use its focus.
expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]);
});
it("gets the correct active transport with multi_sfu", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "multi_sfu" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual(mockFocus);
// If there is an older member we still use our own focus in multi sfu.
expect(membership.getTransport(oldestMembership)).toBe(mockFocus);
});
it("does not provide focus if the selection method is unknown", () => {
const membership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
foci_preferred: [mockFocus],
focus_active: { type: "livekit", focus_selection: "unknown" },
});
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toBeUndefined();
});
});
describe("correct values from computed fields", () => {
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
// slot_id is application and call_id dependent. So we create
// a membership for each possible combination
// non call application (should not alter call_id even with empty string)
const nonCallMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: "m.not.a.call",
call_id: "",
});
// non "" call id should not be altered
const callMembershipCustomId = createCallMembership(makeMockEvent(), {
...membershipTemplate,
call_id: "customCallId",
});
// for membership (application = m.call and call_id = "") we expect "" -> ROOM
// for legacy events we expect the room to be added automagically
// See INFO_SLOT_ID_LEGACY_CASE comments
expect(membership.slotId).toBe("m.call#ROOM");
expect(membership.slotDescription).toStrictEqual({ id: "ROOM", application: "m.call" });
expect(nonCallMembership.slotId).toBe("m.not.a.call#");
expect(nonCallMembership.slotDescription).toStrictEqual({ id: "", application: "m.not.a.call" });
expect(callMembershipCustomId.slotId).toBe("m.call#customCallId");
expect(callMembershipCustomId.slotDescription).toStrictEqual({
id: "customCallId",
application: "m.call",
});
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" });
});
it("returns correct scope", () => {
expect(membership.scope).toBe("m.room");
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("@alice:example.org:AAAAAAA");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION);
expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now());
expect(membership.isExpired()).toBe(true);
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
});
afterEach(() => {
vi.useFakeTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
});
describe("RtcMembershipData", () => {
function makeMockEvent(originTs = 0, content: IContent = {}): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue(content),
getType: vi.fn().mockReturnValue(EventType.RTCMembership),
} as unknown as MatrixEvent;
}
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
};
it("rejects membership with no slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("rejects membership with slot_id that contains extra #", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#mycall#extra" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.toThrow();
});
it("rejects membership with no application", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined });
}).toThrow();
});
it("rejects membership with incorrect application", () => {
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: { wrong_type_key: "unknown" },
});
}).toThrow();
});
it("rejects membership with no member", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined });
}).toThrow();
});
it("rejects membership with incorrect member", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } });
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@@test" },
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" },
});
}).toThrow();
});
it("rejects membership with incorrect sticky_key", () => {
expect(() => {
createCallMembership(makeMockEvent(), membershipTemplate);
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: 1,
msc4354_sticky_key: undefined,
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
sticky_key: "1",
msc4354_sticky_key: undefined,
});
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined });
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: 1,
sticky_key: "valid",
});
}).toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid",
sticky_key: "valid",
});
}).not.toThrow();
expect(() => {
createCallMembership(makeMockEvent(), {
...membershipTemplate,
msc4354_sticky_key: "valid_but_different",
sticky_key: "valid",
});
}).toThrow();
});
// TODO link prev event
it.todo("considers memberships unexpired if local age low enough");
it.todo("considers memberships expired if local age large enough");
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
const oldestMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
rtc_transports: [{ type: "oldest_transport" }],
});
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
// if we are the oldest member we use our focus.
expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" });
// If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu)
expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" });
});
});
describe("correct values from computed fields", () => {
const membership = createCallMembership(makeMockEvent(), membershipTemplate);
it("returns correct sender", () => {
expect(membership.sender).toBe("@alice:example.org");
});
it("returns correct eventId", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
});
it("returns correct call intent", () => {
expect(membership.callIntent).toBe("voice");
});
it("returns correct application", () => {
expect(membership.application).toStrictEqual("m.call");
});
it("returns correct applicationData", () => {
expect(membership.applicationData).toStrictEqual({
"type": "m.call",
"m.call.id": "",
"m.call.intent": "voice",
});
});
it("returns correct scope", () => {
expect(membership.scope).toBe(undefined);
});
it("returns correct membershipID", () => {
expect(membership.membershipID).toBe("xyzHASHxyz");
});
it("returns correct unused fields", () => {
expect(membership.getAbsoluteExpiry()).toBe(undefined);
expect(membership.getMsUntilExpiry()).toBe(undefined);
expect(membership.isExpired()).toBe(false);
});
});
it("uses unpadded base64 for RTC backend identities", async () => {
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
expect(membership.rtcBackendIdentity).toBe("jUZ0Q1yF5nV3LlAI5xfD1I7BPnAytJaPEAR57EXjJ6s");
});
});
});