From 5d091e91c3306f22a5e258bd26ed97b78e882fc3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 4 Mar 2026 18:02:17 +0000 Subject: [PATCH] Update e2e tests and header Don't add normalisation of widget headers either --- .../widget-lifecycle/element-web/README.md | 1 - .../element-web/e2e/widget-lifecycle.spec.ts | 28 ++++++++++++++- .../element-web/src/WidgetLifecycleModule.ts | 11 ++---- .../widget-lifecycle/element-web/src/index.ts | 1 - .../src/utils/constructWidgetPermissions.ts | 1 + .../element-web/src/utils/matchPattern.ts | 1 + .../src/utils/normalizeWidgetUrl.ts | 16 --------- .../tests/WidgetLifecycleModule.test.ts | 2 +- .../tests/constructWidgetPermissions.test.ts | 1 + .../element-web/tests/matchPattern.test.ts | 35 +++++++++++++++++++ 10 files changed, 69 insertions(+), 28 deletions(-) delete mode 100644 modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts create mode 100644 modules/widget-lifecycle/element-web/tests/matchPattern.test.ts diff --git a/modules/widget-lifecycle/element-web/README.md b/modules/widget-lifecycle/element-web/README.md index dd4c5ab6d1..7cc0bc734c 100644 --- a/modules/widget-lifecycle/element-web/README.md +++ b/modules/widget-lifecycle/element-web/README.md @@ -19,7 +19,6 @@ This is useful when widgets have multiple routes or capabilities include variabl ## Matching and precedence -- Widget URLs are normalized by stripping query parameters and hash fragments before matching. - Patterns ending in `*` are matched by prefix; other patterns must match exactly. - If multiple rules match, the most specific match wins per field. - The capabilities allow-list is not merged across rules; the most specific rule that defines it wins. diff --git a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts index 268a9dece5..ed3ac965d8 100644 --- a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts +++ b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts @@ -47,6 +47,8 @@ test.use({ test.describe("Widget Lifecycle", () => { test.describe("trusted widgets", () => { + // Configure the module to pre-approve the widget URL for preloading, identity tokens, + // and the m.room.topic state event capability. test.use({ config: { "io.element.element-web-modules.widget-lifecycle": { @@ -62,6 +64,9 @@ test.describe("Widget Lifecycle", () => { }); test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => { + // A bot creates a room with the widget pinned to the top panel, then invites the test user. + // Because the widget was added by a different user (the bot), Element would normally show a + // preload consent dialog before loading it — this test verifies that dialog is skipped. const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( "POST", @@ -102,6 +107,11 @@ test.describe("Widget Lifecycle", () => { await page.getByText("Trusted Widget").click(); await page.getByRole("button", { name: "Accept" }).click(); + // No preload dialog should appear — the widget loads immediately. + await expect(page.getByRole("button", { name: "Continue" })).not.toBeVisible(); + + // The widget greets the user by ID, proving the identity token was also auto-approved + // and passed to the widget without any consent prompts. await expect( page .frameLocator('iframe[title="Trusted Widget"]') @@ -119,6 +129,7 @@ test.describe("Widget Lifecycle", () => { name: "Capabilities Widget", }, ); + // The widget requests two capabilities: m.room.topic (in the allowlist) and m.room.name (not in the allowlist). await homeserver.csApi.request<{ event_id: string }>( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, @@ -150,11 +161,13 @@ test.describe("Widget Lifecycle", () => { await page.getByText("Capabilities Widget").click(); await page.getByRole("button", { name: "Accept" }).click(); + // A capabilities approval dialog should appear since m.room.name was not pre-approved. await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); }); }); test.describe("untrusted widgets", () => { + // No widget URLs are pre-approved, so all lifecycle prompts should be shown to the user. test.use({ config: { "io.element.element-web-modules.widget-lifecycle": { @@ -163,7 +176,11 @@ test.describe("Widget Lifecycle", () => { }, }); - test("shows dialogs for untrusted widgets", async ({ page, user, homeserver }, testInfo) => { + test("shows preload, capabilities, and OpenID dialogs for untrusted widgets", async ({ + page, + user, + homeserver, + }, testInfo) => { const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( "POST", @@ -204,6 +221,15 @@ test.describe("Widget Lifecycle", () => { await page.getByText("Untrusted Widget").click(); await page.getByRole("button", { name: "Accept" }).click(); + // 1. Preload consent dialog — shown because the widget was added by another user (the bot). + await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + // 2. Capabilities dialog — the widget requests m.room.topic once it loads. + await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); + await page.getByRole("button", { name: "Approve" }).click(); + + // 3. OpenID identity dialog — the widget requests an identity token after capabilities are granted. await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); }); }); diff --git a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts index 17453af6bc..ce17d783f8 100644 --- a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts @@ -6,11 +6,9 @@ Please see LICENSE files in the repository root for full details. */ import type { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element-hq/element-web-module-api"; - import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config"; import { constructWidgetPermissions } from "./utils/constructWidgetPermissions"; import { matchPattern } from "./utils/matchPattern"; -import { normalizeWidgetUrl } from "./utils/normalizeWidgetUrl"; /** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */ export type WidgetLifecycleApiAdapter = Pick< @@ -53,14 +51,12 @@ export default class WidgetLifecycleModule implements Module { } private preapprovePreload(widget: WidgetDescriptor): boolean { - const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); - const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); return configuration.preload_approved === true; } private preapproveIdentity(widget: WidgetDescriptor): boolean { - const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); - const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); return configuration.identity_approved === true; } @@ -68,8 +64,7 @@ export default class WidgetLifecycleModule implements Module { widget: WidgetDescriptor, requestedCapabilities: Set, ): Set | undefined { - const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); - const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); const capabilitiesApproved = configuration.capabilities_approved; if (!capabilitiesApproved) return undefined; diff --git a/modules/widget-lifecycle/element-web/src/index.ts b/modules/widget-lifecycle/element-web/src/index.ts index 843638fc29..7cff3faacc 100644 --- a/modules/widget-lifecycle/element-web/src/index.ts +++ b/modules/widget-lifecycle/element-web/src/index.ts @@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details. */ import type { ModuleFactory } from "@element-hq/element-web-module-api"; - import WidgetLifecycleModule from "./WidgetLifecycleModule"; export default WidgetLifecycleModule satisfies ModuleFactory; diff --git a/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts index 9f4ae1346d..07943c1b82 100644 --- a/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts +++ b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts @@ -1,5 +1,6 @@ /* Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts index 7531474569..8271649291 100644 --- a/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts +++ b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts @@ -1,5 +1,6 @@ /* Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts b/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts deleted file mode 100644 index 794db023bf..0000000000 --- a/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -/** - * Strips query parameters and fragment from a widget URL for matching against config patterns. - */ -export function normalizeWidgetUrl(widgetUrl: string): string { - const url = new URL(widgetUrl); - url.search = ""; - url.hash = ""; - return url.toString(); -} diff --git a/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts index 9970a9aa00..d513a083f9 100644 --- a/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts +++ b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts @@ -47,7 +47,7 @@ const createApi = (config: unknown = {}) => { const widget: WidgetDescriptor = { id: "widget-id", - templateUrl: "https://example.com", + templateUrl: "https://example.com/", creatorUserId: "@user-id", kind: "room", }; diff --git a/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts index e84881acd0..25bc60660d 100644 --- a/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts +++ b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts @@ -1,5 +1,6 @@ /* Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/tests/matchPattern.test.ts b/modules/widget-lifecycle/element-web/tests/matchPattern.test.ts new file mode 100644 index 0000000000..0cc2c25c03 --- /dev/null +++ b/modules/widget-lifecycle/element-web/tests/matchPattern.test.ts @@ -0,0 +1,35 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import { matchPattern } from "../src/utils/matchPattern"; + +describe("matchPattern", () => { + it.each([ + "*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + "org.matrix.msc2762.receive.state_event:m.custom#state_key*", + ])("matches %s", (pattern) => { + expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(true); + }); + + it.each([ + "org.matrix.msc2762.receive.state_event:", + "org.matrix.msc2762.receive.state_event:m.custom", + "org.matrix.msc2762.receive.state_event:m.custom#other_key", + "org.matrix.msc2762.receive.*:m.custom#state_key", + "org.matrix.msc2762.receive.room_event:m.custom", + ])("does not match %s", (pattern) => { + expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(false); + }); +});