From d30e6f25d387ae782c58dae0a5722e4ed8d21b70 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:05:49 +0200 Subject: [PATCH] Ensure correct room version is used and permissions are appropriately sert when creating rooms (#31464) * Check default PL when setting a new PL in createRoom * Drop custom PL setting for video rooms * lint files * Add room version test * Cleanup test * fix import --- src/createRoom.ts | 55 +++++-------- test/unit-tests/createRoom-test.ts | 120 +++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 59 deletions(-) diff --git a/src/createRoom.ts b/src/createRoom.ts index e819bf39af..b73c06d8db 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -1,4 +1,5 @@ /* +Copyright 2025 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd @@ -39,10 +40,10 @@ import { findDMForUser } from "./utils/dm/findDMForUser"; import { privateShouldBeEncrypted } from "./utils/rooms"; import { shouldForceDisableEncryption } from "./utils/crypto/shouldForceDisableEncryption"; import { waitForMember } from "./utils/membership"; -import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; +import { doesRoomVersionSupport, PreferredRoomVersions } from "./utils/PreferredRoomVersions"; import SettingsStore from "./settings/SettingsStore"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto"; -import { ElementCallEventType, ElementCallMemberEventType } from "./call-types"; +import { ElementCallMemberEventType } from "./call-types"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -159,32 +160,19 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro }; // Video rooms require custom power levels - if (opts.roomType === RoomType.ElementVideo) { + if (opts.roomType === RoomType.ElementVideo || opts.roomType === RoomType.UnstableCall) { createOpts.power_level_content_override = { events: { ...DEFAULT_EVENT_POWER_LEVELS, // Allow all users to send call membership updates - [JitsiCall.MEMBER_EVENT_TYPE]: 0, - // Make widgets immutable, even to admins - "im.vector.modular.widgets": 200, - }, - users: { - // Temporarily give ourselves the power to set up a widget - [client.getSafeUserId()]: 200, - }, - }; - } else if (opts.roomType === RoomType.UnstableCall) { - createOpts.power_level_content_override = { - events: { - ...DEFAULT_EVENT_POWER_LEVELS, - // Allow all users to send call membership updates - [ElementCallMemberEventType.name]: 0, - // Make calls immutable, even to admins - [ElementCallEventType.name]: 200, - }, - users: { - // Temporarily give ourselves the power to set up a call - [client.getSafeUserId()]: 200, + [opts.roomType === RoomType.ElementVideo + ? JitsiCall.MEMBER_EVENT_TYPE + : ElementCallMemberEventType.name]: 0, + // Ensure all but admins can't change widgets + // A previous version of the code prevented even administrators + // from changing this, but this is not possible now that room creators + // have an immutable power level + ["im.vector.modular.widgets"]: 100, }, }; } @@ -194,8 +182,6 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro ...DEFAULT_EVENT_POWER_LEVELS, // It should always (including non video rooms) be possible to join a group call. [ElementCallMemberEventType.name]: 0, - // Make sure only admins can enable it (DEPRECATED) - [ElementCallEventType.name]: 100, }, }; } @@ -230,7 +216,12 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro }); } - if (opts.joinRule === JoinRule.Knock) { + const defaultRoomVersion = (await client.getCapabilities())["m.room_versions"]?.default ?? "1"; + + if ( + opts.joinRule === JoinRule.Knock && + !doesRoomVersionSupport(defaultRoomVersion, PreferredRoomVersions.KnockRooms) + ) { createOpts.room_version = PreferredRoomVersions.KnockRooms; } @@ -238,7 +229,9 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro createOpts.initial_state.push(makeSpaceParentEvent(opts.parentSpace, true)); if (opts.joinRule === JoinRule.Restricted) { - createOpts.room_version = PreferredRoomVersions.RestrictedRooms; + if (!doesRoomVersionSupport(defaultRoomVersion, PreferredRoomVersions.KnockRooms)) { + createOpts.room_version = PreferredRoomVersions.RestrictedRooms; + } createOpts.initial_state.push({ type: EventType.RoomJoinRules, @@ -354,15 +347,9 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro if (opts.roomType === RoomType.ElementVideo) { // Set up this video room with a Jitsi call await JitsiCall.create(await room); - - // Reset our power level back to admin so that the widget becomes immutable - await client.setPowerLevel(roomId, client.getUserId()!, 100); } else if (opts.roomType === RoomType.UnstableCall) { // Set up this video room with an Element call ElementCall.create(await room); - - // Reset our power level back to admin so that the call becomes immutable - await client.setPowerLevel(roomId, client.getUserId()!, 100); } }) .then( diff --git a/test/unit-tests/createRoom-test.ts b/test/unit-tests/createRoom-test.ts index 4c0ee86278..2d70889770 100644 --- a/test/unit-tests/createRoom-test.ts +++ b/test/unit-tests/createRoom-test.ts @@ -1,4 +1,5 @@ /* +Copyright 2025 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -7,19 +8,33 @@ Please see LICENSE files in the repository root for full details. */ import { mocked, type Mocked } from "jest-mock"; -import { type MatrixClient, type Device, Preset, RoomType } from "matrix-js-sdk/src/matrix"; +import { + type MatrixClient, + type Device, + Preset, + RoomType, + JoinRule, + RoomVersionStability, +} from "matrix-js-sdk/src/matrix"; import { type CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; -import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg, getMockClientWithEventEmitter } from "../test-utils"; +import { + stubClient, + setupAsyncStoreWithClient, + mockPlatformPeg, + getMockClientWithEventEmitter, + mkRoom, +} from "../test-utils"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import WidgetStore from "../../src/stores/WidgetStore"; import WidgetUtils from "../../src/utils/WidgetUtils"; import { JitsiCall, ElementCall } from "../../src/models/Call"; import createRoom, { checkUserIsAllowedToChangeEncryption, canEncryptToAllUsers } from "../../src/createRoom"; import SettingsStore from "../../src/settings/SettingsStore"; -import { ElementCallEventType, ElementCallMemberEventType } from "../../src/call-types"; +import { ElementCallMemberEventType } from "../../src/call-types"; import DMRoomMap from "../../src/utils/DMRoomMap"; +import { PreferredRoomVersions } from "../../src/utils/PreferredRoomVersions"; describe("createRoom", () => { mockPlatformPeg(); @@ -103,14 +118,12 @@ describe("createRoom", () => { jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue(); const createCallSpy = jest.spyOn(JitsiCall, "create"); - const userId = client.getUserId()!; - const roomId = await createRoom(client, { roomType: RoomType.ElementVideo }); + await createRoom(client, { roomType: RoomType.ElementVideo }); const [ [ { power_level_content_override: { - users: { [userId]: userPower }, events: { "im.vector.modular.widgets": widgetPower, [JitsiCall.MEMBER_EVENT_TYPE]: callMemberPower, @@ -120,44 +133,30 @@ describe("createRoom", () => { ], ] = client.createRoom.mock.calls as any; // no good type - // We should have had enough power to be able to set up the widget - expect(userPower).toBeGreaterThanOrEqual(widgetPower); // and should have actually set it up expect(createCallSpy).toHaveBeenCalled(); // All members should be able to update their connected devices expect(callMemberPower).toEqual(0); // widget should be immutable for admins - expect(widgetPower).toBeGreaterThan(100); - // and we should have been reset back to admin - expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100); + expect(widgetPower).toEqual(100); }); it("sets up Element video rooms correctly", async () => { - const userId = client.getUserId()!; const createCallSpy = jest.spyOn(ElementCall, "create"); const callMembershipSpy = jest.spyOn(MatrixRTCSession, "callMembershipsForRoom"); callMembershipSpy.mockReturnValue([]); - const roomId = await createRoom(client, { roomType: RoomType.UnstableCall }); + await createRoom(client, { roomType: RoomType.UnstableCall }); - const userPower = client.createRoom.mock.calls[0][0].power_level_content_override?.users?.[userId]; - const callPower = - client.createRoom.mock.calls[0][0].power_level_content_override?.events?.[ElementCallEventType.name]; const callMemberPower = client.createRoom.mock.calls[0][0].power_level_content_override?.events?.[ElementCallMemberEventType.name]; - // We should have had enough power to be able to set up the call - expect(userPower).toBeGreaterThanOrEqual(callPower!); // and should have actually set it up expect(createCallSpy).toHaveBeenCalled(); // All members should be able to update their connected devices expect(callMemberPower).toEqual(0); - // call should be immutable for admins - expect(callPower).toBeGreaterThan(100); - // and we should have been reset back to admin - expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100); }); it("doesn't create calls in non-video-rooms", async () => { @@ -177,12 +176,9 @@ describe("createRoom", () => { await createRoom(client, {}); - const callPower = - client.createRoom.mock.calls[0][0].power_level_content_override?.events?.[ElementCallEventType.name]; const callMemberPower = client.createRoom.mock.calls[0][0].power_level_content_override?.events?.[ElementCallMemberEventType.name]; - expect(callPower).toBe(100); expect(callMemberPower).toBe(0); }); @@ -212,6 +208,80 @@ describe("createRoom", () => { }), ); }); + + describe("room versions", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it("should use the correct room version for knocking when default does not support it", async () => { + client.getCapabilities.mockResolvedValue({ + "m.room_versions": { + default: "1", + available: { + [PreferredRoomVersions.KnockRooms]: RoomVersionStability.Stable, + "1": RoomVersionStability.Stable, + }, + }, + }); + await createRoom(client, { joinRule: JoinRule.Knock }); + expect(client.createRoom).toHaveBeenCalledWith( + expect.objectContaining({ + room_version: PreferredRoomVersions.KnockRooms, + }), + ); + }); + it("should use the default room version for knocking when default supports it", async () => { + client.getCapabilities.mockResolvedValue({ + "m.room_versions": { + default: "12", + available: { + [PreferredRoomVersions.KnockRooms]: RoomVersionStability.Stable, + "12": RoomVersionStability.Stable, + }, + }, + }); + await createRoom(client, { joinRule: JoinRule.Knock }); + expect(client.createRoom).toHaveBeenCalledWith( + expect.not.objectContaining({ + room_version: expect.anything(), + }), + ); + }); + it("should use the correct room version for restricted join rules when default does not support it", async () => { + client.getCapabilities.mockResolvedValue({ + "m.room_versions": { + default: "1", + available: { + [PreferredRoomVersions.RestrictedRooms]: RoomVersionStability.Stable, + "1": RoomVersionStability.Stable, + }, + }, + }); + await createRoom(client, { parentSpace: mkRoom(client, "!parent"), joinRule: JoinRule.Restricted }); + expect(client.createRoom).toHaveBeenCalledWith( + expect.objectContaining({ + room_version: PreferredRoomVersions.RestrictedRooms, + }), + ); + }); + it("should use the default room version for restricted join rules when default supports it", async () => { + client.getCapabilities.mockResolvedValue({ + "m.room_versions": { + default: "12", + available: { + [PreferredRoomVersions.RestrictedRooms]: RoomVersionStability.Stable, + "12": RoomVersionStability.Stable, + }, + }, + }); + await createRoom(client, { parentSpace: mkRoom(client, "!parent"), joinRule: JoinRule.Restricted }); + expect(client.createRoom).toHaveBeenCalledWith( + expect.not.objectContaining({ + room_version: expect.anything(), + }), + ); + }); + }); }); describe("canEncryptToAllUsers", () => {