Update e2e tests and header

Don't add normalisation of widget headers either
This commit is contained in:
David Langley
2026-03-04 18:02:17 +00:00
parent 6aa454c646
commit 5d091e91c3
10 changed files with 69 additions and 28 deletions
@@ -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.
@@ -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();
});
});
@@ -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<string>,
): Set<string> | 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;
@@ -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;
@@ -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.
@@ -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.
@@ -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();
}
@@ -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",
};
@@ -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.
@@ -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);
});
});