import { assert, expect } from "chai"; import { Appservice, Intent, MatrixError } from "matrix-bot-sdk"; import { BridgeConfigGenericWebhooks, BridgeGenericWebhooksConfigYAML } from "../../src/config/sections"; import { GenericHookConnection, GenericHookConnectionState } from "../../src/Connections/GenericHook"; import { MessageSenderClient, IMatrixSendMessage } from "../../src/MatrixSender"; import { LocalMQ } from "../../src/MessageQueue/LocalMQ"; import { AppserviceMock } from "../utils/AppserviceMock"; import { MemoryStorageProvider } from "../../src/Stores/MemoryStorageProvider"; import { BridgeConfig } from "../../src/config/Config"; import { ProvisionConnectionOpts } from "../../src/Connections"; import { add } from "date-fns"; const ROOM_ID = "!foo:bar"; const V1TFFunction = "result = `The answer to '${data.question}' is ${data.answer}`;"; const V2TFFunction = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}"; const V2TFFunctionWithReturn = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}; return;"; async function testSimpleWebhook(connection: GenericHookConnection, mq: LocalMQ, testValue: string) { const webhookData = {simple: testValue}; const messagePromise = handleMessage(mq); await connection.onGenericHook(webhookData); expect(await messagePromise).to.deep.equal({ roomId: ROOM_ID, sender: connection.getUserId(), content: { body: "Received webhook data:\n\n```json\n\n{\n \"simple\": \"" + testValue + "\"\n}\n\n```", format: "org.matrix.custom.html", formatted_body: "
Received webhook data:
{\n \"simple\": \"" + testValue + "\"\n}
",
msgtype: "m.notice",
"uk.half-shot.hookshot.webhook_data": webhookData,
},
type: 'm.room.message',
});
}
const ConfigDefaults = {enabled: true, urlPrefix: "https://example.com/webhookurl"};
function createGenericHook(
state: Partialsimple-message
", msgtype: "m.notice", "uk.half-shot.hookshot.webhook_data": webhookData, }, type: 'm.room.message', }); }); it("will handle a hook event containing markdown", async () => { const webhookData = {text: "**bold-message** _italic-message_"}; const [connection, mq] = createGenericHook(); const messagePromise = handleMessage(mq); await connection.onGenericHook(webhookData); expect(await messagePromise).to.deep.equal({ roomId: ROOM_ID, sender: connection.getUserId(), content: { body: "**bold-message** _italic-message_", format: "org.matrix.custom.html", formatted_body: "bold-message italic-message
", msgtype: "m.notice", "uk.half-shot.hookshot.webhook_data": webhookData, }, type: 'm.room.message', }); }); it("will handle a hook event containing markdown with newlines", async () => { const webhookData = {text: "# Oh wow\n\n`some-code`"}; const [connection, mq] = createGenericHook(); const messagePromise = handleMessage(mq); await connection.onGenericHook(webhookData); expect(await messagePromise).to.deep.equal({ roomId: ROOM_ID, sender: connection.getUserId(), content: { body: "# Oh wow\n\n`some-code`", format: "org.matrix.custom.html", formatted_body: "some-code
Received webhook data:
{\n \"username\": \"Bobs-integration\",\n \"type\": 42\n}
",
msgtype: "m.notice",
"uk.half-shot.hookshot.webhook_data": webhookData,
},
type: 'm.room.message',
});
});
it("will handle a hook event with a v1 transformation function", async () => {
const webhookData = {question: 'What is the meaning of life?', answer: 42};
const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V1TFFunction}, {
allowJsTransformationFunctions: true,
}
);
const messagePromise = handleMessage(mq);
await connection.onGenericHook(webhookData);
expect(await messagePromise).to.deep.equal({
roomId: ROOM_ID,
sender: connection.getUserId(),
content: {
body: "Received webhook: The answer to 'What is the meaning of life?' is 42",
format: "org.matrix.custom.html",
formatted_body: "Received webhook: The answer to 'What is the meaning of life?' is 42
", msgtype: "m.notice", "uk.half-shot.hookshot.webhook_data": webhookData, }, type: 'm.room.message', }); }); it("will handle a hook event with a v2 transformation function", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunction}, { allowJsTransformationFunctions: true, } ); const messagePromise = handleMessage(mq); await connection.onGenericHook(webhookData); expect(await messagePromise).to.deep.equal({ roomId: ROOM_ID, sender: connection.getUserId(), content: { body: "The answer to 'What is the meaning of life?' is 42", format: "org.matrix.custom.html", formatted_body: "The answer to 'What is the meaning of life?' is 42
", msgtype: "m.notice", "uk.half-shot.hookshot.webhook_data": webhookData, }, type: 'm.room.message', }); }); it("will handle a hook event with a top-level return", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunctionWithReturn}, { allowJsTransformationFunctions: true, } ); const messagePromise = handleMessage(mq); await connection.onGenericHook(webhookData); expect(await messagePromise).to.deep.equal({ roomId: ROOM_ID, sender: connection.getUserId(), content: { body: "The answer to 'What is the meaning of life?' is 42", format: "org.matrix.custom.html", formatted_body: "The answer to 'What is the meaning of life?' is 42
", msgtype: "m.notice", "uk.half-shot.hookshot.webhook_data": webhookData, }, type: 'm.room.message', }); }); it("will fail to handle a webhook with an invalid script", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, { allowJsTransformationFunctions: true, } ); const messagePromise = handleMessage(mq); await connection.onGenericHook(webhookData); expect(await messagePromise).to.deep.equal({ roomId: ROOM_ID, sender: connection.getUserId(), content: { body: "Webhook received but failed to process via transformation function", format: "org.matrix.custom.html", formatted_body: "Webhook received but failed to process via transformation function
", msgtype: "m.notice", "uk.half-shot.hookshot.webhook_data": webhookData, }, type: 'm.room.message', }); }); it("will handle a message containing floats", async () => { const [connection, mq] = createGenericHook(); let messagePromise = handleMessage(mq); await connection.onGenericHook({ simple: 1.2345 }); let message = await messagePromise; expect(message.roomId).to.equal(ROOM_ID); expect(message.sender).to.equal(connection.getUserId()); expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal({ simple: "1.2345" }); messagePromise = handleMessage(mq); await connection.onGenericHook({ a: { deep: { object: { containing: 1.2345 } } } }); message = await messagePromise; expect(message.roomId).to.equal(ROOM_ID); expect(message.sender).to.equal(connection.getUserId()); expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal({ a: { deep: { object: { containing: "1.2345" }}} }); messagePromise = handleMessage(mq); await connection.onGenericHook({ an_array_of: [1.2345, 6.789], floats: true, }); message = await messagePromise; expect(message.roomId).to.equal(ROOM_ID); expect(message.sender).to.equal(connection.getUserId()); expect(message.content["uk.half-shot.hookshot.webhook_data"]).to.deep.equal({ an_array_of: ["1.2345", "6.789"], floats: true, }); }); it("should handle simple hook events with user Id prefix", async () => { const [connection, mq] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); await testSimpleWebhook(connection, mq, "data1"); // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 await testSimpleWebhook(connection, mq, "data2"); }); it("should invite a configured puppet to the room if it's unable to join", async () => { const senderUserId = "@_webhooks_some-name:example.test"; const [connection, mq, as, botIntent] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); const intent = as.getIntentForUserId(senderUserId); let hasInvited = false; // This should fail the first time, then pass once we've tried to invite the user intent.ensureJoined = async (roomId: string) => { if (hasInvited) { return roomId; } expect(roomId).to.equal(ROOM_ID); throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, { }) }; // This should invite the puppet user. botIntent.underlyingClient.inviteUser = async (userId: string, roomId: string) => { expect(userId).to.equal(senderUserId); expect(roomId).to.equal(ROOM_ID); hasInvited = true; } // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 await testSimpleWebhook(connection, mq, "data1"); // Only pass if we've actually bothered to invite the bot. expect(hasInvited).to.be.true; }); it("should fail a message if a bot cannot join a room", async () => { const senderUserId = "@_webhooks_some-name:example.test"; const [connection, mq, as] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); const intent = as.getIntentForUserId(senderUserId); // This should fail the first time, then pass once we've tried to invite the user intent.ensureJoined = () => { throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) }; try { // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 await testSimpleWebhook(connection, mq, "data1"); } catch (ex) { expect(ex.message).to.contain(`Could not ensure that ${senderUserId} is in ${ROOM_ID}`) } }); it('should fail to create a hook with an invalid expiry time', () => { for (const expirationDate of [0, 1, -1, false, true, {}, [], new Date(), ""]) { expect(() => GenericHookConnection.validateState({ name: "beep", expirationDate, })).to.throw("'expirationDate' must be a non-empty string"); } for (const expirationDate of ["no", "\0", "true", " 2024", "2024-01-01", "15:56", "2024-01-01 15:16"]) { expect(() => GenericHookConnection.validateState({ name: "beep", expirationDate, })).to.throw("'expirationDate' must be a valid date"); } }); it('should fail to create a hook with a too short expiry time', async () => { const as = AppserviceMock.create(); try { await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { name: "foo", expirationDate: new Date().toISOString(), }, { as: as, intent: as.botIntent, config: { generic: new BridgeConfigGenericWebhooks(ConfigDefaults) } as unknown as BridgeConfig, messageClient: new MessageSenderClient(new LocalMQ()), storage: new MemoryStorageProvider(), } as unknown as ProvisionConnectionOpts); assert.fail('Expected function to throw'); } catch (ex) { expect(ex.message).to.contain('Expiration date must at least be a hour in the future'); } }); it('should fail to create a hook with a too long expiry time', async () => { const as = AppserviceMock.create(); try { await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { name: "foo", expirationDate: add(new Date(), { days: 1, seconds: 1}).toISOString(), }, { as: as, intent: as.botIntent, config: { generic: new BridgeConfigGenericWebhooks({ ...ConfigDefaults, maxExpiryTime: '1d' }) } as unknown as BridgeConfig, messageClient: new MessageSenderClient(new LocalMQ()), storage: new MemoryStorageProvider(), } as unknown as ProvisionConnectionOpts); assert.fail('Expected function to throw'); } catch (ex) { expect(ex.message).to.contain('Expiration date cannot exceed the configured max expiry time'); } }); it('should fail to create a hook without an expiry time when required by config', async () => { const as = AppserviceMock.create(); try { await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { name: "foo", }, { as: as, intent: as.botIntent, config: { generic: new BridgeConfigGenericWebhooks({ ...ConfigDefaults, maxExpiryTime: '1d', requireExpiryTime: true, }) } as unknown as BridgeConfig, messageClient: new MessageSenderClient(new LocalMQ()), storage: new MemoryStorageProvider(), } as unknown as ProvisionConnectionOpts); assert.fail('Expected function to throw'); } catch (ex) { expect(ex.message).to.contain('Expiration date must be set'); } }); it('should create a hook and handle a request within the expiry time', async () => { const [connection, mq] = createGenericHook({ expirationDate: add(new Date(), { seconds: 30 }).toISOString(), }); await testSimpleWebhook(connection, mq, "test"); }); it('should reject requests to an expired hook', async () => { const [connection] = createGenericHook({ expirationDate: new Date().toISOString(), }); expect(await connection.onGenericHook({test: "value"})).to.deep.equal({ error: "This hook has expired", statusCode: 404, successful: false, }); }); })