From 49223e720b73002f4f0d476e6501f69ed3cd21cd Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 12 Oct 2026 10:33:58 +0200 Subject: [PATCH] Cover url transports end to end The specs replace the transports the e2e homeserver advertises with each shape a migrating deployment may produce and record which token endpoints each side used: a url-only or combined transport on both sides goes through the CS API alone, a legacy guest joining a url host uses the legacy endpoint to publish and the CS API to subscribe, a subscriber falls back to the JWT service when the homeserver lacks MSC4195, a publisher gets an error instead, a delayed leave delegated through the CS API fires on disconnect, and subscribers get tokens for a remote homeserver's SFU over federation. The JWT service's app service registration has to cover users of the e2e Synapse's real server name for the get_token flow, and lk-jwt-service 0.8.0 implements the endpoints. --- backend/app-service-othersite.yaml | 2 +- backend/app-service.yaml | 2 +- docker-compose-dev.yml | 4 +- playwright/url-transport.spec.ts | 405 +++++++++++++++++++++++++++++ 4 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 playwright/url-transport.spec.ts diff --git a/backend/app-service-othersite.yaml b/backend/app-service-othersite.yaml index ef2a7ada7..a80322baf 100644 --- a/backend/app-service-othersite.yaml +++ b/backend/app-service-othersite.yaml @@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service" namespaces: users: - exclusive: false - regex: "@.*:homeserver-1" # Cover all local users + regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users # Required for Synapse to call POST /_matrix/app/v1/ping on this service. url: "http://auth-server-1:16080" # Enable membership look-ups via /is_joined. diff --git a/backend/app-service.yaml b/backend/app-service.yaml index b480df64e..75716ca59 100644 --- a/backend/app-service.yaml +++ b/backend/app-service.yaml @@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service" namespaces: users: - exclusive: false - regex: "@.*:homeserver" # Cover all local users + regex: "@.*:synapse\\.m\\.localhost" # Cover all local users # Required for Synapse to call POST /_matrix/app/v1/ping on this service. url: "http://auth-server:6080" # Enable membership look-ups via /is_joined. diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 3de89ec38..2db9a63a0 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -3,7 +3,7 @@ networks: services: auth-service: - image: ghcr.io/element-hq/lk-jwt-service:0.7.0 + image: ghcr.io/element-hq/lk-jwt-service:0.8.0 pull_policy: always hostname: auth-server environment: @@ -33,7 +33,7 @@ services: - ecbackend auth-service-1: - image: ghcr.io/element-hq/lk-jwt-service:0.7.0 + image: ghcr.io/element-hq/lk-jwt-service:0.8.0 pull_policy: always hostname: auth-server-1 environment: diff --git a/playwright/url-transport.spec.ts b/playwright/url-transport.spec.ts new file mode 100644 index 000000000..994c0f5cc --- /dev/null +++ b/playwright/url-transport.spec.ts @@ -0,0 +1,405 @@ +/* +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 { SpaHelpers } from "./spa-helpers"; + +const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu"; +const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt"; + +const otherHomeserver = { + base_url: "https://synapse.othersite.m.localhost", + server_name: "synapse.othersite.m.localhost", +}; +const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu"; + +interface Transport { + type: "livekit"; + url?: string; + livekit_service_url?: string; +} + +/** + * The transport shapes a homeserver may advertise while migrating to the + * latest MSC4195. `legacy` is what the e2e Synapse advertises on its own. + */ +const shapes = { + url: { type: "livekit", url: sfuUrl }, + both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl }, + legacy: null, +} satisfies Record; +type Shape = keyof typeof shapes; + +/** Which endpoint a page asks for its SFU tokens. */ +type Flow = "cs-api" | "service-legacy" | "service-default"; + +const cases: { + hostShape: Shape; + guestShape: Shape; + hostFlows: Flow[]; + guestFlows: Flow[]; +}[] = [ + { + hostShape: "url", + guestShape: "url", + hostFlows: ["cs-api"], + guestFlows: ["cs-api"], + }, + { + hostShape: "both", + guestShape: "both", + hostFlows: ["cs-api"], + guestFlows: ["cs-api"], + }, + { + hostShape: "url", + guestShape: "legacy", + hostFlows: ["cs-api", "service-default"], + guestFlows: ["service-legacy", "cs-api"], + }, +]; + +for (const { hostShape, guestShape, hostFlows, guestFlows } of cases) { + test(`One to one call with ${hostShape} host and ${guestShape} guest transport`, async ({ + browser, + page, + browserName, + }) => { + skipOnFirefox(browserName); + + // The host starts the call. + await advertiseTransport(page, shapes[hostShape]); + const hostRequests = recordAuthRequests(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest joins. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await advertiseTransport(guestPage, shapes[guestShape]); + const guestRequests = recordAuthRequests(guestPage); + await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara"); + + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // The host and guest ran through the expected flows. + expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows)); + expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows)); + + // All requests succeeded and the SFU URL was set on C-S requests. + for (const request of [...hostRequests, ...guestRequests]) { + expect(request.status).toBe(200); + if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl); + } + }); +} + +test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({ + browser, + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host starts the call with both transport shapes. + await advertiseTransport(page, shapes.both); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest joins but their C-S requests fail. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await rejectCsApiTokens(guestPage); + const guestRequests = recordAuthRequests(guestPage); + await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara"); + + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // The guest ran through all flows. + expect(flowsOf(guestRequests)).toEqual( + new Set(["cs-api", "service-legacy", "service-default"]), + ); + + // The C-S token requests failed. + for (const request of guestRequests) { + expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200); + } + + // The C-S token requests preceeded the fallback request. + const flows = guestRequests.map((r) => r.flow); + expect(flows.indexOf("service-default")).toBeGreaterThan( + flows.indexOf("cs-api"), + ); +}); + +test("Publisher shows an error when the homeserver lacks MSC4195", async ({ + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host tries to start a call with only the C-S transport shape available but + // their C-S requests fail. + await advertiseTransport(page, shapes.url); + await rejectCsApiTokens(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true); + + // We land on an error page. + await expect( + page.getByRole("heading", { name: "Something went wrong" }), + ).toBeVisible(); + await expect( + page.getByText( + "The authorization service for your media server (SFU) is out of date.", + ), + ).toBeVisible(); +}); + +test("Delayed leave event delegated to a url transport gets sent on disconnect", async ({ + browser, + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host starts the call. + await advertiseTransport(page, shapes.url); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest joins. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await advertiseTransport(guestPage, shapes.url); + const delayedLeave = recordDelayedLeave(guestPage); + const delegations = recordDelegations(guestPage); + await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara"); + + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // The guest handed its delayed leave to the homeserver, after probing it for + // delegation support with bodiless requests. + await expect + .poll(() => delegations.at(-1)) + .toEqual({ status: 200, sfuUrl, delayId: delayedLeave.delayId }); + const probes = delegations.slice(0, -1); + expect(probes.length).toBeGreaterThan(0); + for (const probe of probes) expect(probe).toEqual({ status: 401 }); + + // The delay is long enough that a leave within the window below cannot come + // from the delayed event expiring on its own. + expect(delayedLeave.delayMs).toBeGreaterThan(60_000); + + // The guest drops without leaving. + await guestPage.reload(); + + // The host eventually sees the guest go once the delayed event fires. + await expect(page.getByTestId("videoTile")).toHaveCount(1, { + timeout: 30_000, + }); +}); + +test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({ + browser, + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host lives on the other homeserver and publishes on its SFU. + await useHomeserver(page, otherHomeserver); + await advertiseTransport(page, { type: "livekit", url: otherSfuUrl }); + const hostRequests = recordAuthRequests(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest lives on the default homeserver and joins over federation. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await advertiseTransport(guestPage, shapes.url); + const guestRequests = recordAuthRequests(guestPage); + await SpaHelpers.joinCallFromInviteLink( + guestPage, + `${inviteLink}&viaServers=${otherHomeserver.server_name}`, + "Pevara", + ); + + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // Each side got a token for its own SFU and for the other side's SFU, all + // through its own homeserver. + for (const requests of [hostRequests, guestRequests]) { + expect(flowsOf(requests)).toEqual(new Set(["cs-api"])); + expect(new Set(requests.map((r) => r.sfuUrl))).toEqual( + new Set([sfuUrl, otherSfuUrl]), + ); + for (const request of requests) expect(request.status).toBe(200); + } +}); + +/** Skips the calling test if we're running on Firefox. */ +function skipOnFirefox(browserName: string): void { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); +} + +/** Points this page's Element Call at another homeserver. */ +async function useHomeserver( + page: Page, + homeserver: typeof otherHomeserver, +): Promise { + await page.route("**/config.json", async (route) => { + const response = await route.fetch(); + const config = (await response.json()) as Record; + await route.fulfill({ + response, + json: { + ...config, + default_server_config: { "m.homeserver": homeserver }, + }, + }); + }); +} + +/** + * Maps recorded requests to a set of flows. Publisher and subscriber connections + * start concurrently so using arrays for comparison isn't convenient. + */ +function flowsOf(requests: AuthRequest[]): Set { + return new Set(requests.map((r) => r.flow)); +} + +/** + * Replaces the transports the homeserver advertises to this page. The real + * response is fetched first so its CORS headers carry over. + */ +async function advertiseTransport( + page: Page, + transport: Transport | null, +): Promise { + if (transport === null) return; + 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] } }); + }); +} + +/** + * Makes the homeserver look like one without MSC4195: its `get_token` + * endpoint answers as Synapse does for an unknown route. + */ +async function rejectCsApiTokens(page: Page): Promise { + await page.route("**/rtc/livekit/get_token", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + await route.fulfill({ + status: 404, + json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); +} + +/** A recorded delayed leave event. */ +interface DelayedLeave { + delayMs?: number; + delayId?: string; +} + +/** Records the delayed leave event the page schedules on joining. */ +function recordDelayedLeave(page: Page): DelayedLeave { + const delayedLeave: DelayedLeave = {}; + page.on("response", (response) => { + const delay = new URL(response.request().url()).searchParams.get( + "org.matrix.msc4140.delay", + ); + if (delay === null || response.status() !== 200) return; + delayedLeave.delayMs = Number(delay); + response + .json() + .then((body: { delay_id: string }) => { + delayedLeave.delayId = body.delay_id; + }) + .catch(() => {}); + }); + return delayedLeave; +} + +/** A recorded delegation request for a delayed leave event. */ +interface Delegation { + status: number; + sfuUrl?: string; + delayId?: string; +} + +/** Records requests to the MSC4195 `delegate_delayed_leave` endpoint. */ +function recordDelegations(page: Page): Delegation[] { + const delegations: Delegation[] = []; + page.on("response", (response) => { + const request = response.request(); + if ( + request.method() !== "POST" || + !request.url().endsWith("/rtc/livekit/delegate_delayed_leave") + ) + return; + const body = request.postDataJSON() as { + url: string; + delay_id: string; + } | null; + delegations.push({ + status: response.status(), + ...(body && { sfuUrl: body.url, delayId: body.delay_id }), + }); + }); + return delegations; +} + +/** A recorded token request. */ +interface AuthRequest { + flow: Flow; + status: number; + /** The SFU a request to the MSC4195 `get_token` endpoint asked for. */ + sfuUrl?: string; +} + +/** Records token requests. */ +function recordAuthRequests(page: Page): AuthRequest[] { + const requests: AuthRequest[] = []; + page.on("response", (response) => { + const request = response.request(); + if (request.method() !== "POST") return; + const url = request.url(); + if (url.endsWith("/rtc/livekit/get_token")) { + requests.push({ + flow: "cs-api", + status: response.status(), + sfuUrl: (request.postDataJSON() as { url: string }).url, + }); + } else if (url.endsWith("/livekit/jwt/sfu/get")) { + requests.push({ flow: "service-legacy", status: response.status() }); + } else if (url.endsWith("/livekit/jwt/get_token")) { + requests.push({ flow: "service-default", status: response.status() }); + } + }); + return requests; +}