Implement new widget permissions module api (#32565)
* Add widget lifecycle API at top level * Integrate while still falling back to the legacy api * Remove WidgetKind * Update module api to the one that includes the new widget lifecycle api * lint * Make preload checks easier to understand - Have single code path for preload checks. - Remove duplicated logic for preapproveIdentity check - Fix headers * lint
This commit is contained in:
@@ -37,6 +37,7 @@ import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/Ele
|
||||
import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
|
||||
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { ModuleRunner } from "../../../../../src/modules/ModuleRunner";
|
||||
import { ModuleApi } from "../../../../../src/modules/Api";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
|
||||
@@ -426,6 +427,30 @@ describe("AppTile", () => {
|
||||
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should auto-approve preload via new widget lifecycle API", async () => {
|
||||
// Legacy module API denies preload
|
||||
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
|
||||
if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === app1.id) {
|
||||
(opts as ApprovalOpts).approved = false;
|
||||
}
|
||||
});
|
||||
|
||||
// New API approves preload
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapprovePreload").mockResolvedValue(true);
|
||||
|
||||
// userId and creatorUserId are different so legacy path would show "Continue"
|
||||
const renderResult = render(
|
||||
<MatrixClientContext.Provider value={cli}>
|
||||
<AppTile key={app1.id} app={app1} room={r1} userId="@user1" creatorUserId="@userAnother" />
|
||||
</MatrixClientContext.Provider>,
|
||||
);
|
||||
|
||||
// The new API runs async in componentDidMount, so wait for it to take effect
|
||||
await waitFor(() => {
|
||||
expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a maximised (centered) widget", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockImplementation(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Widget } from "matrix-widget-api";
|
||||
|
||||
import type { WidgetDescriptor } from "@element-hq/element-web-module-api";
|
||||
import { WidgetLifecycleApi, toWidgetDescriptor } from "../../../src/modules/WidgetLifecycleApi";
|
||||
|
||||
const mkDescriptor = (overrides: Partial<WidgetDescriptor> = {}): WidgetDescriptor => ({
|
||||
id: "test-widget",
|
||||
templateUrl: "https://example.org/widget",
|
||||
creatorUserId: "@alice:example.org",
|
||||
type: "m.custom",
|
||||
origin: "https://example.org",
|
||||
roomId: "!room:example.org",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("WidgetLifecycleApi", () => {
|
||||
let api: WidgetLifecycleApi;
|
||||
|
||||
beforeEach(() => {
|
||||
api = new WidgetLifecycleApi();
|
||||
});
|
||||
|
||||
describe("toWidgetDescriptor", () => {
|
||||
it("converts a Widget to a WidgetDescriptor", () => {
|
||||
const widget = new Widget({
|
||||
id: "w1",
|
||||
creatorUserId: "@bob:example.org",
|
||||
type: "m.jitsi",
|
||||
url: "https://jitsi.example.org/meet?conf=$matrix_room_id",
|
||||
});
|
||||
const descriptor = toWidgetDescriptor(widget, "!room:example.org");
|
||||
expect(descriptor).toEqual({
|
||||
id: "w1",
|
||||
templateUrl: "https://jitsi.example.org/meet?conf=$matrix_room_id",
|
||||
creatorUserId: "@bob:example.org",
|
||||
type: "m.jitsi",
|
||||
origin: "https://jitsi.example.org",
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits roomId when not provided", () => {
|
||||
const widget = new Widget({
|
||||
id: "w1",
|
||||
creatorUserId: "@bob:example.org",
|
||||
type: "m.custom",
|
||||
url: "https://example.org",
|
||||
});
|
||||
const descriptor = toWidgetDescriptor(widget);
|
||||
expect(descriptor.roomId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerPreloadApprover", () => {
|
||||
it("accepts a single registration", () => {
|
||||
expect(() => api.registerPreloadApprover(() => true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on double registration", () => {
|
||||
api.registerPreloadApprover(() => true);
|
||||
expect(() => api.registerPreloadApprover(() => true)).toThrow(
|
||||
"Widget lifecycle preload approver already registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerIdentityApprover", () => {
|
||||
it("accepts a single registration", () => {
|
||||
expect(() => api.registerIdentityApprover(() => true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on double registration", () => {
|
||||
api.registerIdentityApprover(() => true);
|
||||
expect(() => api.registerIdentityApprover(() => true)).toThrow(
|
||||
"Widget lifecycle identity approver already registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerCapabilitiesApprover", () => {
|
||||
it("accepts a single registration", () => {
|
||||
expect(() => api.registerCapabilitiesApprover(() => new Set())).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on double registration", () => {
|
||||
api.registerCapabilitiesApprover(() => new Set());
|
||||
expect(() => api.registerCapabilitiesApprover(() => new Set())).toThrow(
|
||||
"Widget lifecycle capabilities approver already registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preapprovePreload", () => {
|
||||
const widget = mkDescriptor();
|
||||
|
||||
it("returns false when no approver registered", async () => {
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when approver returns true", async () => {
|
||||
api.registerPreloadApprover(() => true);
|
||||
expect(await api.preapprovePreload(widget)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when approver returns false", async () => {
|
||||
api.registerPreloadApprover(() => false);
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when approver returns undefined", async () => {
|
||||
api.registerPreloadApprover(() => undefined);
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs error when approver throws", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
api.registerPreloadApprover(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await api.preapprovePreload(widget)).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Widget preload approver failed", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preapproveIdentity", () => {
|
||||
const widget = mkDescriptor();
|
||||
|
||||
it("returns false when no approver registered", async () => {
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when approver returns true", async () => {
|
||||
api.registerIdentityApprover(() => true);
|
||||
expect(await api.preapproveIdentity(widget)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when approver returns false", async () => {
|
||||
api.registerIdentityApprover(() => false);
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when approver returns undefined", async () => {
|
||||
api.registerIdentityApprover(() => undefined);
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs error when approver throws", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
api.registerIdentityApprover(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await api.preapproveIdentity(widget)).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Widget identity approver failed", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preapproveCapabilities", () => {
|
||||
const widget = mkDescriptor();
|
||||
const requested = new Set(["cap1", "cap2", "cap3"]);
|
||||
|
||||
it("returns undefined when no approver registered", async () => {
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the set from the approver", async () => {
|
||||
api.registerCapabilitiesApprover(() => new Set(["cap1", "cap2"]));
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toEqual(new Set(["cap1", "cap2"]));
|
||||
});
|
||||
|
||||
it("passes widget and requested capabilities to the approver", async () => {
|
||||
const approver = jest.fn().mockReturnValue(new Set(["cap1"]));
|
||||
api.registerCapabilitiesApprover(approver);
|
||||
await api.preapproveCapabilities(widget, requested);
|
||||
expect(approver).toHaveBeenCalledWith(widget, requested);
|
||||
});
|
||||
|
||||
it("returns undefined when approver returns undefined", async () => {
|
||||
api.registerCapabilitiesApprover(() => undefined);
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when approver throws", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
api.registerCapabilitiesApprover(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Widget capabilities approver failed", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver";
|
||||
import { mkEvent, stubClient } from "../../../test-utils";
|
||||
import { ModuleRunner } from "../../../../src/modules/ModuleRunner";
|
||||
import { ModuleApi } from "../../../../src/modules/Api";
|
||||
import dis from "../../../../src/dispatcher/dispatcher";
|
||||
import Modal from "../../../../src/Modal";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
@@ -187,6 +188,76 @@ describe("ElementWidgetDriver", () => {
|
||||
expect(listener).toHaveBeenCalledWith(openIdUpdate);
|
||||
});
|
||||
|
||||
it("approves capabilities via new widget lifecycle API", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]);
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue(
|
||||
requestedCapabilities,
|
||||
);
|
||||
|
||||
const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
|
||||
expect(approvedCapabilities).toEqual(requestedCapabilities);
|
||||
});
|
||||
|
||||
it("falls back to legacy module API when new API returns undefined for capabilities", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]);
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue(undefined);
|
||||
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation(
|
||||
(lifecycleEvent, opts, widgetInfo, requested) => {
|
||||
if (lifecycleEvent === WidgetLifecycle.CapabilitiesRequest) {
|
||||
(opts as CapabilitiesOpts).approvedCapabilities = requested;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
|
||||
expect(approvedCapabilities).toEqual(requestedCapabilities);
|
||||
});
|
||||
|
||||
it("approves identity via new widget lifecycle API", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(true);
|
||||
|
||||
const listener = jest.fn();
|
||||
const observer = new SimpleObservable<IOpenIDUpdate>();
|
||||
observer.onUpdate(listener);
|
||||
await driver.askOpenID(observer);
|
||||
|
||||
const openIdUpdate: IOpenIDUpdate = {
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await client.getOpenIdToken(),
|
||||
};
|
||||
expect(listener).toHaveBeenCalledWith(openIdUpdate);
|
||||
});
|
||||
|
||||
it("falls back to legacy module API when new API returns false for identity", async () => {
|
||||
const driver = mkDefaultDriver();
|
||||
|
||||
jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(false);
|
||||
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
|
||||
if (lifecycleEvent === WidgetLifecycle.IdentityRequest) {
|
||||
(opts as ApprovalOpts).approved = true;
|
||||
}
|
||||
});
|
||||
|
||||
const listener = jest.fn();
|
||||
const observer = new SimpleObservable<IOpenIDUpdate>();
|
||||
observer.onUpdate(listener);
|
||||
await driver.askOpenID(observer);
|
||||
|
||||
const openIdUpdate: IOpenIDUpdate = {
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await client.getOpenIdToken(),
|
||||
};
|
||||
expect(listener).toHaveBeenCalledWith(openIdUpdate);
|
||||
});
|
||||
|
||||
describe("sendToDevice", () => {
|
||||
const contentMap = {
|
||||
"@alice:example.org": {
|
||||
|
||||
Reference in New Issue
Block a user