/* 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. */ import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; import { rejectToastIfExists } from "@element-hq/element-web-playwright-common"; import { test, expect } from "../../playwright/element-web-test.ts"; declare module "@element-hq/element-web-module-api" { interface Config { "io.element.element-web-modules.widget-lifecycle": { widget_permissions: { [url: string]: { preload_approved?: boolean; identity_approved?: boolean; capabilities_approved?: string[]; }; }; }; } } const WIDGET_URL = "http://localhost:8080/widget.html"; test.use({ displayName: "Timmy", synapseConfig: async ({ synapseConfig, _homeserver: homeserver }, use) => { (homeserver as SynapseContainer).withConfigField("listeners[0].resources[0].names", ["client", "openid"]); await use(synapseConfig); }, page: async ({ context, page, moduleDir }, use) => { await context.route(`${WIDGET_URL}*`, async (route) => { await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" }); }); await page.goto("/"); await use(page); }, bypassCSP: true, launchOptions: { args: ["--disable-web-security"], }, }); 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": { widget_permissions: { [`${WIDGET_URL}*`]: { preload_approved: true, identity_approved: true, capabilities_approved: ["org.matrix.msc2762.receive.state_event:m.room.topic"], }, }, }, }, }); test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => { await rejectToastIfExists(page, "Verify this device"); // 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", "/v3/createRoom", bot.accessToken, { name: "Trusted Widget", }, ); await homeserver.csApi.request<{ event_id: string }>( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, bot.accessToken, { id: "1", creatorUserId: bot.userId, type: "custom", name: "Trusted Widget", url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`, }, ); await homeserver.csApi.request( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, bot.accessToken, { widgets: { "1": { container: "top", }, }, }, ); await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { user_id: user.userId, }); 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"]') .getByRole("heading", { name: `Hello ${user.userId}!` }), ).toBeVisible(); }); test("prompts for capabilities not in the allowlist", async ({ page, user, homeserver }, testInfo) => { await rejectToastIfExists(page, "Verify this device"); const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( "POST", "/v3/createRoom", bot.accessToken, { 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`, bot.accessToken, { id: "1", creatorUserId: bot.userId, type: "custom", name: "Capabilities Widget", url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic,org.matrix.msc2762.receive.state_event:m.room.name`, }, ); await homeserver.csApi.request( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, bot.accessToken, { widgets: { "1": { container: "top", }, }, }, ); await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { user_id: user.userId, }); 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": { widget_permissions: {}, }, }, }); test("shows preload, capabilities, and OpenID dialogs for untrusted widgets", async ({ page, user, homeserver, }, testInfo) => { await rejectToastIfExists(page, "Verify this device"); const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( "POST", "/v3/createRoom", bot.accessToken, { name: "Untrusted Widget", }, ); await homeserver.csApi.request<{ event_id: string }>( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, bot.accessToken, { id: "1", creatorUserId: bot.userId, type: "custom", name: "Untrusted Widget", url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`, }, ); await homeserver.csApi.request( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, bot.accessToken, { widgets: { "1": { container: "top", }, }, }, ); await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { user_id: user.userId, }); 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(); }); }); });