Update e2e tests and header
Don't add normalisation of widget headers either
This commit is contained in:
@@ -19,7 +19,6 @@ This is useful when widgets have multiple routes or capabilities include variabl
|
|||||||
|
|
||||||
## Matching and precedence
|
## 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.
|
- Patterns ending in `*` are matched by prefix; other patterns must match exactly.
|
||||||
- If multiple rules match, the most specific match wins per field.
|
- 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.
|
- 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("Widget Lifecycle", () => {
|
||||||
test.describe("trusted widgets", () => {
|
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({
|
test.use({
|
||||||
config: {
|
config: {
|
||||||
"io.element.element-web-modules.widget-lifecycle": {
|
"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) => {
|
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 bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
|
||||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -102,6 +107,11 @@ test.describe("Widget Lifecycle", () => {
|
|||||||
await page.getByText("Trusted Widget").click();
|
await page.getByText("Trusted Widget").click();
|
||||||
await page.getByRole("button", { name: "Accept" }).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(
|
await expect(
|
||||||
page
|
page
|
||||||
.frameLocator('iframe[title="Trusted Widget"]')
|
.frameLocator('iframe[title="Trusted Widget"]')
|
||||||
@@ -119,6 +129,7 @@ test.describe("Widget Lifecycle", () => {
|
|||||||
name: "Capabilities Widget",
|
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 }>(
|
await homeserver.csApi.request<{ event_id: string }>(
|
||||||
"PUT",
|
"PUT",
|
||||||
`/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`,
|
`/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.getByText("Capabilities Widget").click();
|
||||||
await page.getByRole("button", { name: "Accept" }).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();
|
await expect(page.getByRole("button", { name: "Approve" })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("untrusted widgets", () => {
|
test.describe("untrusted widgets", () => {
|
||||||
|
// No widget URLs are pre-approved, so all lifecycle prompts should be shown to the user.
|
||||||
test.use({
|
test.use({
|
||||||
config: {
|
config: {
|
||||||
"io.element.element-web-modules.widget-lifecycle": {
|
"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 bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot");
|
||||||
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -204,6 +221,15 @@ test.describe("Widget Lifecycle", () => {
|
|||||||
await page.getByText("Untrusted Widget").click();
|
await page.getByText("Untrusted Widget").click();
|
||||||
await page.getByRole("button", { name: "Accept" }).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();
|
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 type { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element-hq/element-web-module-api";
|
||||||
|
|
||||||
import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config";
|
import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config";
|
||||||
import { constructWidgetPermissions } from "./utils/constructWidgetPermissions";
|
import { constructWidgetPermissions } from "./utils/constructWidgetPermissions";
|
||||||
import { matchPattern } from "./utils/matchPattern";
|
import { matchPattern } from "./utils/matchPattern";
|
||||||
import { normalizeWidgetUrl } from "./utils/normalizeWidgetUrl";
|
|
||||||
|
|
||||||
/** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */
|
/** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */
|
||||||
export type WidgetLifecycleApiAdapter = Pick<
|
export type WidgetLifecycleApiAdapter = Pick<
|
||||||
@@ -53,14 +51,12 @@ export default class WidgetLifecycleModule implements Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private preapprovePreload(widget: WidgetDescriptor): boolean {
|
private preapprovePreload(widget: WidgetDescriptor): boolean {
|
||||||
const normalizedUrl = normalizeWidgetUrl(widget.templateUrl);
|
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||||
const configuration = constructWidgetPermissions(this.config, normalizedUrl);
|
|
||||||
return configuration.preload_approved === true;
|
return configuration.preload_approved === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private preapproveIdentity(widget: WidgetDescriptor): boolean {
|
private preapproveIdentity(widget: WidgetDescriptor): boolean {
|
||||||
const normalizedUrl = normalizeWidgetUrl(widget.templateUrl);
|
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||||
const configuration = constructWidgetPermissions(this.config, normalizedUrl);
|
|
||||||
return configuration.identity_approved === true;
|
return configuration.identity_approved === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +64,7 @@ export default class WidgetLifecycleModule implements Module {
|
|||||||
widget: WidgetDescriptor,
|
widget: WidgetDescriptor,
|
||||||
requestedCapabilities: Set<string>,
|
requestedCapabilities: Set<string>,
|
||||||
): Set<string> | undefined {
|
): Set<string> | undefined {
|
||||||
const normalizedUrl = normalizeWidgetUrl(widget.templateUrl);
|
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||||
const configuration = constructWidgetPermissions(this.config, normalizedUrl);
|
|
||||||
const capabilitiesApproved = configuration.capabilities_approved;
|
const capabilitiesApproved = configuration.capabilities_approved;
|
||||||
|
|
||||||
if (!capabilitiesApproved) return undefined;
|
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 type { ModuleFactory } from "@element-hq/element-web-module-api";
|
||||||
|
|
||||||
import WidgetLifecycleModule from "./WidgetLifecycleModule";
|
import WidgetLifecycleModule from "./WidgetLifecycleModule";
|
||||||
|
|
||||||
export default WidgetLifecycleModule satisfies ModuleFactory;
|
export default WidgetLifecycleModule satisfies ModuleFactory;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2026 Element Creations Ltd.
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2026 Element Creations Ltd.
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE files in the repository root for full details.
|
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 = {
|
const widget: WidgetDescriptor = {
|
||||||
id: "widget-id",
|
id: "widget-id",
|
||||||
templateUrl: "https://example.com",
|
templateUrl: "https://example.com/",
|
||||||
creatorUserId: "@user-id",
|
creatorUserId: "@user-id",
|
||||||
kind: "room",
|
kind: "room",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2026 Element Creations Ltd.
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
Copyright 2023 Nordeck IT + Consulting GmbH
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE files in the repository root for full details.
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user