Absorb remainder of element-modules into monorepo
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<!--
|
||||
Minimal fixture widget used by e2e tests to exercise the Matrix Widget API lifecycle:
|
||||
1. Element requests capabilities → widget declares what it needs
|
||||
2. Element notifies approval → widget requests an OpenID token
|
||||
3. Element grants the token → widget resolves the user's Matrix ID and displays it
|
||||
|
||||
The `caps` query parameter controls which capabilities the widget requests (comma-separated).
|
||||
Pass `caps=none` to request none. Defaults to m.room.topic if omitted.
|
||||
The `hsUrl` query parameter is required for the OpenID userinfo lookup in step 3.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Demo Widget</title>
|
||||
<script>
|
||||
const getCapabilities = () => {
|
||||
const caps = new URLSearchParams(window.location.search).get("caps");
|
||||
if (!caps) {
|
||||
return ["org.matrix.msc2762.receive.state_event:m.room.topic"];
|
||||
}
|
||||
if (caps === "none") return [];
|
||||
return caps
|
||||
.split(",")
|
||||
.map((cap) => cap.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
let sendEventCount = 0;
|
||||
window.onmessage = async (ev) => {
|
||||
if (ev.data.action === "capabilities") {
|
||||
// Step 1: Element is asking what capabilities this widget needs.
|
||||
window.parent.postMessage(
|
||||
Object.assign(
|
||||
{
|
||||
response: {
|
||||
capabilities: getCapabilities(),
|
||||
},
|
||||
},
|
||||
ev.data,
|
||||
),
|
||||
"*",
|
||||
);
|
||||
} else if (ev.data.action === "notify_capabilities") {
|
||||
// Step 2: Capabilities have been approved. Request an OpenID token
|
||||
// so we can identify the current user in step 3.
|
||||
window.parent.postMessage(
|
||||
{
|
||||
api: "fromWidget",
|
||||
widgetId: ev.data.widgetId,
|
||||
requestId: "widget-" + sendEventCount,
|
||||
action: "get_openid",
|
||||
data: {},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else if (ev.data.action === "get_openid" && ev.data.response?.state === "allowed") {
|
||||
// Step 3: Token granted — exchange it for the user's Matrix ID and display it.
|
||||
// Tests assert on this heading to verify identity was passed through correctly.
|
||||
const { access_token } = ev.data.response;
|
||||
|
||||
const hsUrl = new URLSearchParams(window.location.search).get("hsUrl");
|
||||
const response = await fetch(
|
||||
`${hsUrl}/_matrix/federation/v1/openid/userinfo?access_token=${access_token}`,
|
||||
);
|
||||
const { sub } = await response.json();
|
||||
|
||||
const titleElement = document.getElementById("title");
|
||||
titleElement.innerText = `Hello ${sub}!`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">Hello unknown!</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user