diff --git a/playwright/widget/url-transport.test.ts b/playwright/widget/url-transport.test.ts new file mode 100644 index 000000000..c4e39cfdd --- /dev/null +++ b/playwright/widget/url-transport.test.ts @@ -0,0 +1,108 @@ +/* +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. +*/ + +import { expect, type Page, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; + +const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu"; +const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt"; + +/** Which endpoint a page asks for its SFU tokens. */ +type Flow = "cs-api" | "service-legacy" | "service-default"; + +widgetTest( + "Call between a url + livekit_service_url transport and a legacy one", + async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + test.slow(); + + const [host, guest] = await Promise.all([ + addUser("Androl", HOST1), + addUser("Pevara", HOST1), + ]); + + // The host's homeserver has started advertising a `url` next to its JWT + // service; the guest's still advertises the JWT service alone. + await advertiseTransport(host.page, { + type: "livekit", + url: sfuUrl, + livekit_service_url: serviceUrl, + }); + // Element Web answers the widget from a cache it filled at login, so make + // it fetch the transports again now that they are rerouted + await host.clientHandle.evaluate(async (client) => { + await client.cachedRtcTransports.refresh(); + }); + const hostFlows = recordFlows(host.page); + const guestFlows = recordFlows(guest.page); + + const roomName = "Transport Room"; + await TestHelpers.createRoom(roomName, host.page, [guest.mxId]); + await TestHelpers.acceptRoomInvite(roomName, guest.page); + + await TestHelpers.startCallInCurrentRoom(host.page, false); + await TestHelpers.joinCallFromLobby(host.page); + await TestHelpers.joinCallInCurrentRoom(guest.page); + + for (const user of [host, guest]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + await expect(frame.getByTestId("videoTile")).toHaveCount(2, { + timeout: 10000, + }); + await expect(frame.getByText("Waiting for media...")).not.toBeVisible({ + timeout: 10000, + }); + await TestHelpers.expectVisibleVideoCount(frame, 2); + } + + // Element Web does not expose the MSC4195 endpoints to widgets yet, so + // neither side reached the CS API. The host published through the JWT + // service's legacy endpoint and subscribed to the guest through its + // default one; the guest published the same way and fell back to the + // default endpoint for the host's transport. + expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"])); + expect(guestFlows).toEqual(new Set(["service-legacy", "service-default"])); + }, +); + +/** + * Replaces the transports the homeserver advertises to this page, including + * to the widget host, which fetches them on the widget's behalf. The real + * response is fetched first so its CORS headers carry over. + */ +async function advertiseTransport( + page: Page, + transport: { type: "livekit"; url?: string; livekit_service_url?: string }, +): Promise { + await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const response = await route.fetch(); + await route.fulfill({ response, json: { rtc_transports: [transport] } }); + }); +} + +/** Records which token endpoints the page's widget asks. */ +function recordFlows(page: Page): Set { + const flows = new Set(); + page.on("response", (response) => { + const request = response.request(); + if (request.method() !== "POST") return; + const url = request.url(); + if (url.endsWith("/rtc/livekit/get_token")) flows.add("cs-api"); + else if (url.endsWith("/livekit/jwt/sfu/get")) flows.add("service-legacy"); + else if (url.endsWith("/livekit/jwt/get_token")) + flows.add("service-default"); + }); + return flows; +} diff --git a/src/livekit/auth/openID.test.ts b/src/livekit/auth/openID.test.ts index a722dcd0a..7498b8fe1 100644 --- a/src/livekit/auth/openID.test.ts +++ b/src/livekit/auth/openID.test.ts @@ -169,6 +169,36 @@ describe("getSFUConfig", () => { }); }); + it("should use the default endpoint when subscribing", async () => { + fetchMock.post("https://sfu.example.org/get_token", (url, opts) => { + const body = JSON.parse(opts.body as string); + expect(body).toMatchObject({ + room_id: "!example_room_id", + slot_id: "m.call#ROOM", + member: { + id: ownMemberMock.memberId, + claimed_user_id: ownMemberMock.userId, + claimed_device_id: ownMemberMock.deviceId, + }, + }); + return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } }; + }); + const config = await getSFUConfig({ ...params, role: "subscriber" }); + expect(config.jwt).toBe(testJWTToken); + expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false); + }); + + it("should fail when subscribing if the default endpoint is missing", async () => { + // Nothing but the catch-all 404 answers the default endpoint. Subscribers + // must not fall back to the legacy endpoint, which would hand them a + // legacy identity. + await expect( + getSFUConfig({ ...params, role: "subscriber" }), + ).rejects.toThrow(FailToGetOpenIdToken); + expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true); + expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false); + }); + it("should retry fetching the openid token", async () => { let count = 0; matrixClient.getOpenIdToken.mockImplementation(async () => { diff --git a/src/state/SessionBehaviors.test.ts b/src/state/SessionBehaviors.test.ts index fca24767c..d9da6f830 100644 --- a/src/state/SessionBehaviors.test.ts +++ b/src/state/SessionBehaviors.test.ts @@ -1,16 +1,25 @@ /* -Copyright 2025 Element Creations Ltd. +Copyright 2025, 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. */ import { describe, expect, it, vi } from "vitest"; -import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc"; +import { + MatrixRTCSessionEvent, + type Transport, + type UnstableLivekitTransport, +} from "matrix-js-sdk/lib/matrixrtc"; import { EventEmitter } from "events"; -import { createKeyRotationSuppressed$ } from "./SessionBehaviors"; -import { testScope } from "../utils/test"; +import { + createKeyRotationSuppressed$, + membershipsAndTransports$, +} from "./SessionBehaviors"; +import { Epoch } from "./ObservableScope"; +import { constant } from "./Behavior"; +import { mockRtcMembership, testScope } from "../utils/test"; describe("SessionBehaviors", () => { describe("createKeyRotationSuppressed$", () => { @@ -38,4 +47,54 @@ describe("SessionBehaviors", () => { expect(keyRotationSuppressed$.value).toBe(false); }); }); + + describe("membershipsAndTransports$", () => { + const serviceTransport: UnstableLivekitTransport = { + type: "livekit", + livekit_service_url: "https://lk.example.org", + }; + const urlTransport: UnstableLivekitTransport = { + type: "livekit", + url: "wss://sfu.sample.com", + }; + + it("locates each member's transport on the member's homeserver", () => { + const bob = mockRtcMembership("@bob:example.org", "DEV000", { + fociPreferred: [serviceTransport], + }); + const carl = mockRtcMembership("@carl:sample.com", "DEV111", { + fociPreferred: [urlTransport], + }); + + const { membershipsWithTransport$, transports$ } = + membershipsAndTransports$( + testScope(), + constant(new Epoch([bob, carl])), + ); + + expect(transports$.value.value).toEqual([ + { transport: serviceTransport, serverName: "example.org" }, + { transport: urlTransport, serverName: "sample.com" }, + ]); + expect( + membershipsWithTransport$.value.value.map( + (m) => m.transport?.serverName, + ), + ).toEqual(["example.org", "sample.com"]); + }); + + it("leaves out members without a LiveKit transport", () => { + const dave = mockRtcMembership("@dave:example.org", "DEV222", { + fociPreferred: [{ type: "something-else" } as Transport], + }); + + const { membershipsWithTransport$, transports$ } = + membershipsAndTransports$(testScope(), constant(new Epoch([dave]))); + + expect(transports$.value.value).toEqual([]); + expect(membershipsWithTransport$.value.value).toEqual([ + { membership: dave, transport: undefined }, + ]); + }); + }); });