From 6585fb1f55f2f86f5b1a9217a311a5a3fb7ee3c6 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 20 Dec 2022 16:13:37 -0500 Subject: [PATCH] Add actions for integration manager to send and read certain events (#9740) * Add send_event action * Add read_events action * Add e2e tests for send_event * Allow more event types to be read * Fix error message * Add e2e tests for read_events * Run i18n * Improve error messages * Fix types * Remove unused test helpers * Fix more type errors * Run prettier * Fix more type errors * Fix validation Since typeof null is "object" Co-authored-by: Travis Ralston --- .../integration-manager/read_events.spec.ts | 276 ++++++++++++++++++ .../integration-manager/send_event.spec.ts | 261 +++++++++++++++++ src/ScalarMessaging.ts | 175 ++++++++++- src/i18n/strings/en_EN.json | 2 + 4 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 cypress/e2e/integration-manager/read_events.spec.ts create mode 100644 cypress/e2e/integration-manager/send_event.spec.ts diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts new file mode 100644 index 00000000000..662df228137 --- /dev/null +++ b/cypress/e2e/integration-manager/read_events.spec.ts @@ -0,0 +1,276 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { UserCredentials } from "../../support/login"; + +const ROOM_NAME = "Integration Manager Test"; +const USER_DISPLAY_NAME = "Alice"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + +

No response

+ + + +`; + +function openIntegrationManager() { + cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { + cy.contains("Add widgets, bridges & bots").click(); + }); +} + +function sendActionFromIntegrationManager( + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string | boolean, +) { + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#target-room-id").should("exist").type(targetRoomId); + cy.get("#event-type").should("exist").type(eventType); + cy.get("#state-key").should("exist").type(JSON.stringify(stateKey)); + cy.get("#send-action").should("exist").click(); + }); +} + +describe("Integration Manager: Read Events", () => { + let testUser: UserCredentials; + let synapse: SynapseInstance; + let integrationManagerUrl: string; + + beforeEach(() => { + cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { + integrationManagerUrl = url; + }); + cy.startSynapse("default").then((data) => { + synapse = data; + + cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.window().then((win) => { + win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); + win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); + }); + }).then((user) => { + testUser = user; + }); + + cy.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }).as("integrationManager"); + + // Succeed when checking the token is valid + cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { + req.continue((res) => { + return res.send(200, { + user_id: testUser.userId, + }); + }); + }); + + cy.createRoom({ + name: ROOM_NAME, + }).as("roomId"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + it("should read a state event by state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send a state event + cy.getClient() + .then(async (client) => { + return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); + }) + .then((event) => { + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response") + .should("include.text", event.event_id) + .should("include.text", `"content":${JSON.stringify(eventContent)}`); + }); + }); + }); + }); + + it("should read a state event with empty state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send a state event + cy.getClient() + .then(async (client) => { + return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); + }) + .then((event) => { + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response") + .should("include.text", event.event_id) + .should("include.text", `"content":${JSON.stringify(eventContent)}`); + }); + }); + }); + }); + + it("should read state events with any state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "io.element.integrations.installations"; + + const stateKey1 = "state-key-123"; + const eventContent1 = { + foo1: "bar1", + }; + const stateKey2 = "state-key-456"; + const eventContent2 = { + foo2: "bar2", + }; + const stateKey3 = "state-key-789"; + const eventContent3 = { + foo3: "bar3", + }; + + // Send state events + cy.getClient() + .then(async (client) => { + return Promise.all([ + client.sendStateEvent(roomId, eventType, eventContent1, stateKey1), + client.sendStateEvent(roomId, eventType, eventContent2, stateKey2), + client.sendStateEvent(roomId, eventType, eventContent3, stateKey3), + ]); + }) + .then((events) => { + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager( + integrationManagerUrl, + roomId, + eventType, + true, // Any state key + ); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response") + .should("include.text", events[0].event_id) + .should("include.text", `"content":${JSON.stringify(eventContent1)}`) + .should("include.text", events[1].event_id) + .should("include.text", `"content":${JSON.stringify(eventContent2)}`) + .should("include.text", events[2].event_id) + .should("include.text", `"content":${JSON.stringify(eventContent3)}`); + }); + }); + }); + }); + + it("should fail to read an event type which is not allowed", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "com.example.event"; + const stateKey = ""; + + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "Failed to read events"); + }); + }); + }); +}); diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts new file mode 100644 index 00000000000..7b706b047d8 --- /dev/null +++ b/cypress/e2e/integration-manager/send_event.spec.ts @@ -0,0 +1,261 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { UserCredentials } from "../../support/login"; + +const ROOM_NAME = "Integration Manager Test"; +const USER_DISPLAY_NAME = "Alice"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + + +

No response

+ + + +`; + +function openIntegrationManager() { + cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { + cy.contains("Add widgets, bridges & bots").click(); + }); +} + +function sendActionFromIntegrationManager( + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string, + content: Record, +) { + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#target-room-id").should("exist").type(targetRoomId); + cy.get("#event-type").should("exist").type(eventType); + if (stateKey) { + cy.get("#state-key").should("exist").type(stateKey); + } + cy.get("#event-content").should("exist").type(JSON.stringify(content), { parseSpecialCharSequences: false }); + cy.get("#send-action").should("exist").click(); + }); +} + +describe("Integration Manager: Send Event", () => { + let testUser: UserCredentials; + let synapse: SynapseInstance; + let integrationManagerUrl: string; + + beforeEach(() => { + cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { + integrationManagerUrl = url; + }); + cy.startSynapse("default").then((data) => { + synapse = data; + + cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.window().then((win) => { + win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); + win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); + }); + }).then((user) => { + testUser = user; + }); + + cy.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }).as("integrationManager"); + + // Succeed when checking the token is valid + cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { + req.continue((res) => { + return res.send(200, { + user_id: testUser.userId, + }); + }); + }); + + cy.createRoom({ + name: ROOM_NAME, + }).as("roomId"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + it("should send a state event", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "event_id"); + }); + + // Check the event + cy.getClient() + .then(async (client) => { + return await client.getStateEvent(roomId, eventType, stateKey); + }) + .then((event) => { + expect(event).to.deep.equal(eventContent); + }); + }); + }); + + it("should send a state event with empty content", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "io.element.integrations.installations"; + const eventContent = {}; + const stateKey = "state-key-123"; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "event_id"); + }); + + // Check the event + cy.getClient() + .then(async (client) => { + return await client.getStateEvent(roomId, eventType, stateKey); + }) + .then((event) => { + expect(event).to.be.empty; + }); + }); + }); + + it("should send a state event with empty state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "event_id"); + }); + + // Check the event + cy.getClient() + .then(async (client) => { + return await client.getStateEvent(roomId, eventType, stateKey); + }) + .then((event) => { + expect(event).to.deep.equal(eventContent); + }); + }); + }); + + it("should fail to send an event type which is not allowed", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "com.example.event"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "Failed to send event"); + }); + }); + }); +}); diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 14c56bef41a..b1912c484af 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -264,10 +264,36 @@ Get an openID token for the current user session. Request: No parameters Response: - The openId token object as described in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridopenidrequest_token + +send_event +---------- +Sends an event in a room. + +Request: + - type is the event type to send. + - state_key is the state key to send. Omitted if not a state event. + - content is the event content to send. + +Response: + - room_id is the room ID where the event was sent. + - event_id is the event ID of the event which was sent. + +read_events +----------- +Read events from a room. + +Request: + - type is the event type to read. + - state_key is the state key to read, or `true` to read all events of the type. Omitted if not a state event. + +Response: + - events: Array of events. If none found, this will be an empty array. + */ -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; +import { IEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; @@ -295,6 +321,8 @@ enum Action { SetBotOptions = "set_bot_options", SetBotPower = "set_bot_power", GetOpenIdToken = "get_open_id_token", + SendEvent = "send_event", + ReadEvents = "read_events", } function sendResponse(event: MessageEvent, res: any): void { @@ -468,13 +496,13 @@ function setWidget(event: MessageEvent, roomId: string | null): void { } } -function getWidgets(event: MessageEvent, roomId: string): void { +function getWidgets(event: MessageEvent, roomId: string | null): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("You need to be logged in.")); return; } - let widgetStateEvents = []; + let widgetStateEvents: Partial[] = []; if (roomId) { const room = client.getRoom(roomId); @@ -693,6 +721,141 @@ async function getOpenIdToken(event: MessageEvent) { } } +async function sendEvent( + event: MessageEvent<{ + type: string; + state_key?: string; + content?: IContent; + }>, + roomId: string, +) { + const eventType = event.data.type; + const stateKey = event.data.state_key; + const content = event.data.content; + + if (typeof eventType !== "string") { + sendError(event, _t("Failed to send event"), new Error("Invalid 'type' in request")); + return; + } + const allowedEventTypes = ["m.widgets", "im.vector.modular.widgets", "io.element.integrations.installations"]; + if (!allowedEventTypes.includes(eventType)) { + sendError(event, _t("Failed to send event"), new Error("Disallowed 'type' in request")); + return; + } + + if (!content || typeof content !== "object") { + sendError(event, _t("Failed to send event"), new Error("Invalid 'content' in request")); + return; + } + + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t("You need to be logged in.")); + return; + } + + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t("This room is not recognised.")); + return; + } + + if (stateKey !== undefined) { + // state event + try { + const res = await client.sendStateEvent(roomId, eventType, content, stateKey); + sendResponse(event, { + room_id: roomId, + event_id: res.event_id, + }); + } catch (e) { + sendError(event, _t("Failed to send event"), e as Error); + return; + } + } else { + // message event + sendError(event, _t("Failed to send event"), new Error("Sending message events is not implemented")); + return; + } +} + +async function readEvents( + event: MessageEvent<{ + type: string; + state_key?: string | boolean; + limit?: number; + }>, + roomId: string, +) { + const eventType = event.data.type; + const stateKey = event.data.state_key; + const limit = event.data.limit; + + if (typeof eventType !== "string") { + sendError(event, _t("Failed to read events"), new Error("Invalid 'type' in request")); + return; + } + const allowedEventTypes = [ + "m.room.power_levels", + "m.room.encryption", + "m.room.member", + "m.room.name", + "m.widgets", + "im.vector.modular.widgets", + "io.element.integrations.installations", + ]; + if (!allowedEventTypes.includes(eventType)) { + sendError(event, _t("Failed to read events"), new Error("Disallowed 'type' in request")); + return; + } + + let effectiveLimit: number; + if (limit !== undefined) { + if (typeof limit !== "number" || limit < 0) { + sendError(event, _t("Failed to read events"), new Error("Invalid 'limit' in request")); + return; + } + effectiveLimit = Math.min(limit, Number.MAX_SAFE_INTEGER); + } else { + effectiveLimit = Number.MAX_SAFE_INTEGER; + } + + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t("You need to be logged in.")); + return; + } + + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t("This room is not recognised.")); + return; + } + + if (stateKey !== undefined) { + // state events + if (typeof stateKey !== "string" && stateKey !== true) { + sendError(event, _t("Failed to read events"), new Error("Invalid 'state_key' in request")); + return; + } + // When `true` is passed for state key, get events with any state key. + const effectiveStateKey = stateKey === true ? undefined : stateKey; + + let events: MatrixEvent[] = []; + events = events.concat(room.currentState.getStateEvents(eventType, effectiveStateKey as string) || []); + events = events.slice(0, effectiveLimit); + + sendResponse(event, { + events: events.map((e) => e.getEffectiveEvent()), + }); + return; + } else { + // message events + sendError(event, _t("Failed to read events"), new Error("Reading message events is not implemented")); + return; + } +} + const onMessage = function (event: MessageEvent): void { if (!event.origin) { // stupid chrome @@ -786,6 +949,12 @@ const onMessage = function (event: MessageEvent): void { } else if (event.data.action === Action.CanSendEvent) { canSendEvent(event, roomId); return; + } else if (event.data.action === Action.SendEvent) { + sendEvent(event, roomId); + return; + } else if (event.data.action === Action.ReadEvents) { + readEvents(event, roomId); + return; } if (!userId) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 306e0483555..7cf87238ebd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -390,6 +390,8 @@ "Power level must be positive integer.": "Power level must be positive integer.", "You are not in this room.": "You are not in this room.", "You do not have permission to do that in this room.": "You do not have permission to do that in this room.", + "Failed to send event": "Failed to send event", + "Failed to read events": "Failed to read events", "Missing room_id in request": "Missing room_id in request", "Room %(roomId)s not visible": "Room %(roomId)s not visible", "Missing user_id in request": "Missing user_id in request",