From 68aa717826f88c4317ab83d1f37d85fc5bc60e4d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Sep 2021 17:59:11 +0100 Subject: [PATCH] Rework integration tests to work with mx-tester --- package.json | 1 + src/Mjolnir.ts | 4 + src/index.ts | 64 +------------ src/setup.ts | 95 +++++++++++++++++++ test/integration/clientHelper.ts | 57 +++++++++++ .../config}/harness.yaml | 3 +- test/integration/fixtures.ts | 19 ++++ test/integration/helloTest.ts | 32 +++++++ test/integration/mjolnirSetupUtils.ts | 46 ++++----- 9 files changed, 228 insertions(+), 93 deletions(-) create mode 100644 src/setup.ts create mode 100644 test/integration/clientHelper.ts rename test/{harness/config/mjolnir => integration/config}/harness.yaml (99%) create mode 100644 test/integration/fixtures.ts create mode 100644 test/integration/helloTest.ts diff --git a/package.json b/package.json index 39fa782..cff307d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "tslint --project ./tsconfig.json -t stylish", "start:dev": "yarn build && node lib/index.js", "test": "ts-mocha --project ./tsconfig.json test/**/*.ts", + "test-integration": "NODE_ENV=harness ts-mocha --require test/integration/fixtures.ts --project ./tsconfig.json test/integration/**/*Test.ts", "harness": "NODE_ENV=harness ts-node test/harness/launchScript.ts" }, "devDependencies": { diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 8ce085d..c5db346 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -209,6 +209,10 @@ export class Mjolnir { }); } + public stop() { + this.client.stop(); + } + public async addProtectedRoom(roomId: string) { this.protectedRooms[roomId] = Permalinks.forRoom(roomId); diff --git a/src/index.ts b/src/index.ts index 7b92a41..ab8c379 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,17 +20,13 @@ import { LogService, MatrixClient, PantalaimonClient, - Permalinks, RichConsoleLogger, SimpleFsStorageProvider } from "matrix-bot-sdk"; import config from "./config"; -import BanList from "./models/BanList"; -import { Mjolnir } from "./Mjolnir"; import { logMessage } from "./LogProxy"; -import { MembershipEvent } from "matrix-bot-sdk/lib/models/events/MembershipEvent"; -import * as htmlEscape from "escape-html"; import { Healthz } from "./health/healthz"; +import { setupMjolnir } from "./setup"; config.RUNTIME = {}; @@ -58,63 +54,7 @@ if (config.health.healthz.enabled) { config.RUNTIME.client = client; - client.on("room.invite", async (roomId: string, inviteEvent: any) => { - const membershipEvent = new MembershipEvent(inviteEvent); - - const reportInvite = async () => { - if (!config.recordIgnoredInvites) return; // Nothing to do - - await client.sendMessage(config.managementRoom, { - msgtype: "m.text", - body: `${membershipEvent.sender} has invited me to ${roomId} but the config prevents me from accepting the invitation. ` - + `If you would like this room protected, use "!mjolnir rooms add ${roomId}" so I can accept the invite.`, - format: "org.matrix.custom.html", - formatted_body: `${htmlEscape(membershipEvent.sender)} has invited me to ${htmlEscape(roomId)} but the config prevents me from ` - + `accepting the invitation. If you would like this room protected, use !mjolnir rooms add ${htmlEscape(roomId)} ` - + `so I can accept the invite.`, - }); - }; - - if (config.autojoinOnlyIfManager) { - const managers = await client.getJoinedRoomMembers(config.managementRoom); - if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite - } else { - const groupMembers = await client.unstableApis.getGroupUsers(config.acceptInvitesFromGroup); - const userIds = groupMembers.map(m => m.user_id); - if (!userIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite - } - - return client.joinRoom(roomId); - }); - - const banLists: BanList[] = []; - const protectedRooms: { [roomId: string]: string } = {}; - const joinedRooms = await client.getJoinedRooms(); - // Ensure we're also joined to the rooms we're protecting - LogService.info("index", "Resolving protected rooms..."); - for (const roomRef of config.protectedRooms) { - const permalink = Permalinks.parseUrl(roomRef); - if (!permalink.roomIdOrAlias) continue; - - let roomId = await client.resolveRoom(permalink.roomIdOrAlias); - if (!joinedRooms.includes(roomId)) { - roomId = await client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); - } - - protectedRooms[roomId] = roomRef; - } - - // Ensure we're also in the management room - LogService.info("index", "Resolving management room..."); - const managementRoomId = await client.resolveRoom(config.managementRoom); - if (!joinedRooms.includes(managementRoomId)) { - config.managementRoom = await client.joinRoom(config.managementRoom); - } else { - config.managementRoom = managementRoomId; - } - await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); - - const bot = new Mjolnir(client, protectedRooms, banLists); + let bot = await setupMjolnir(client, config); await bot.start(); })().catch(err => { logMessage(LogLevel.ERROR, "index", err); diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..8b29c7d --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,95 @@ +/* +Copyright 2019-2021 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 { LogLevel, LogService, MatrixClient, Permalinks } from "matrix-bot-sdk"; +import { MembershipEvent } from "matrix-bot-sdk/lib/models/events/MembershipEvent"; +import * as htmlEscape from "escape-html"; +import BanList from "./models/BanList"; +import { logMessage } from "./LogProxy"; +import { Mjolnir } from "./Mjolnir"; + +/** + * Adds a listener to the client that will automatically accept invitations. + * @param {MatrixClient} client + * @param options By default accepts invites from anyone. + * @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true. + * @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`. + * @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`. + * @param {string} options.acceptInvitesFromGroup A group of users to accept invites from, ignores invites form users not in this group. + */ +export function addJoinOnInviteListener(client: MatrixClient, options) { + client.on("room.invite", async (roomId: string, inviteEvent: any) => { + const membershipEvent = new MembershipEvent(inviteEvent); + + const reportInvite = async () => { + if (!options.recordIgnoredInvites) return; // Nothing to do + + await client.sendMessage(options.managementRoom, { + msgtype: "m.text", + body: `${membershipEvent.sender} has invited me to ${roomId} but the config prevents me from accepting the invitation. ` + + `If you would like this room protected, use "!mjolnir rooms add ${roomId}" so I can accept the invite.`, + format: "org.matrix.custom.html", + formatted_body: `${htmlEscape(membershipEvent.sender)} has invited me to ${htmlEscape(roomId)} but the config prevents me from ` + + `accepting the invitation. If you would like this room protected, use !mjolnir rooms add ${htmlEscape(roomId)} ` + + `so I can accept the invite.`, + }); + }; + + if (options.autojoinOnlyIfManager) { + const managers = await client.getJoinedRoomMembers(options.managementRoom); + if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite + } else { + const groupMembers = await client.unstableApis.getGroupUsers(options.acceptInvitesFromGroup); + const userIds = groupMembers.map(m => m.user_id); + if (!userIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite + } + + return client.joinRoom(roomId); + }); +} + +export async function setupMjolnir(client, config): Promise { + addJoinOnInviteListener(client, config); + + const banLists: BanList[] = []; + const protectedRooms: { [roomId: string]: string } = {}; + const joinedRooms = await client.getJoinedRooms(); + // Ensure we're also joined to the rooms we're protecting + LogService.info("index", "Resolving protected rooms..."); + for (const roomRef of config.protectedRooms) { + const permalink = Permalinks.parseUrl(roomRef); + if (!permalink.roomIdOrAlias) continue; + + let roomId = await client.resolveRoom(permalink.roomIdOrAlias); + if (!joinedRooms.includes(roomId)) { + roomId = await client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); + } + + protectedRooms[roomId] = roomRef; + } + + // Ensure we're also in the management room + LogService.info("index", "Resolving management room..."); + const managementRoomId = await client.resolveRoom(config.managementRoom); + if (!joinedRooms.includes(managementRoomId)) { + config.managementRoom = await client.joinRoom(config.managementRoom); + } else { + config.managementRoom = managementRoomId; + } + await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); + + return new Mjolnir(client, protectedRooms, banLists); +} \ No newline at end of file diff --git a/test/integration/clientHelper.ts b/test/integration/clientHelper.ts new file mode 100644 index 0000000..ed36fe5 --- /dev/null +++ b/test/integration/clientHelper.ts @@ -0,0 +1,57 @@ +import axios from "axios"; +import { HmacSHA1 } from "crypto-js"; +import { MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk"; +import config from "../../src/config"; + +export async function registerUser(username: string, displayname: string, password: string, admin: boolean) { + let registerUrl = `${config.homeserverUrl}/_synapse/admin/v1/register` + let { data } = await axios.get(registerUrl); + let nonce = data.nonce!; + let mac = HmacSHA1(`${nonce}\0${username}\0${password}\0${admin ? 'admin' : 'notadmin'}`, 'REGISTRATION_SHARED_SECRET'); + return await axios.post(registerUrl, { + nonce, + username, + displayname, + password, + admin, + mac: mac.toString() + }) +} + +/** + * Register a new test user with a unique username. + * @param isAdmin Whether to make the new user an admin. + * @returns A string that is the username and password of a new user. + */ +export async function registerNewTestUser(isAdmin: boolean) { + let isUserValid = false; + let username; + do { + username = `test-user-${Math.floor(Math.random() * 100000)}` + await registerUser(username, username, username, isAdmin).then(_ => isUserValid = true).catch(e => { + if (e.isAxiosError && e.response.data.errcode === 'M_USER_IN_USE') { + // FIXME: Replace with the real logging service. + console.log(`${username} already registered, trying another`); + false // continue and try again + } else { + console.error(`failed to register user ${e}`); + throw e; + } + }) + } while (!isUserValid); + return username; +} + +export async function newTestUser(isAdmin?: boolean): Promise { + const username = await registerNewTestUser(isAdmin); + const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()); + return await pantalaimon.createClientWithCredentials(username, username); +} + +export function noticeListener(targetRoomdId: string, cb) { + return (roomId, event) => { + if (roomId !== targetRoomdId) return; + if (event?.content?.msgtype !== "m.notice") return; + cb(event); + } +} \ No newline at end of file diff --git a/test/harness/config/mjolnir/harness.yaml b/test/integration/config/harness.yaml similarity index 99% rename from test/harness/config/mjolnir/harness.yaml rename to test/integration/config/harness.yaml index e65664e..c70936c 100644 --- a/test/harness/config/mjolnir/harness.yaml +++ b/test/integration/config/harness.yaml @@ -73,8 +73,7 @@ automaticallyRedactForReasons: - "advertising" # A list of rooms to protect (matrix.to URLs) -protectedRooms: - - "https://matrix.to/#/#lobby:localhost:9999" +protectedRooms: [] # Set this option to true to protect every room the bot is joined to. Note that # this effectively makes the protectedRooms and associated commands useless because diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts new file mode 100644 index 0000000..9443ee8 --- /dev/null +++ b/test/integration/fixtures.ts @@ -0,0 +1,19 @@ +import { Mjolnir } from "../../src/Mjolnir"; +import { makeMjolnir } from "./mjolnirSetupUtils"; + +export async function mochaGlobalSetup() { + console.log("Starting mjolnir."); + try { + this.bot = await makeMjolnir() + // do not block on this! + this.bot.start(); + } catch (e) { + console.trace(e); + throw e; + } +} + +export async function mochaGlobalTeardown() { + this.bot.stop(); + console.log('stopping mjolnir'); + } \ No newline at end of file diff --git a/test/integration/helloTest.ts b/test/integration/helloTest.ts new file mode 100644 index 0000000..85f8c40 --- /dev/null +++ b/test/integration/helloTest.ts @@ -0,0 +1,32 @@ +import { doesNotMatch } from "assert"; +import { assert } from "console"; +import config from "../../src/config"; +import { newTestUser, noticeListener } from "./clientHelper" + +// need the start and newTestUser and then the stop call to be in setup and tear down. +describe("help command", () => { + let client; + before(async function () { + client = await newTestUser(true); + await client.start(); + }) + it('Mjolnir responded to !mjolnir help', async function() { + this.timeout(30000); + // send a messgage + await client.joinRoom(config.managementRoom); + // listener for getting the event reply + let reply = new Promise((resolve, reject) => { + client.on('room.message', noticeListener(config.managementRoom, (event) => { + console.log(event.event_id) + if (event.content.body.includes("Print status information")) { + resolve(event); + } + }))}); + // check we get one back + await client.sendMessage(config.managementRoom, {msgtype: "m.text", body: "!mjolnir help"}) + await reply + }) + after(async function () { + await client.stop(); + }) +}) diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 9e3d88e..a562ede 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -24,6 +24,8 @@ import * as HmacSHA1 from 'crypto-js/hmac-sha1'; import axios from 'axios'; import * as path from 'path'; import * as fs from 'fs/promises'; +import { setupMjolnir } from '../../src/setup'; +import { registerUser } from "./clientHelper"; export async function createManagementRoom(client: MatrixClient) { let roomId = await client.createRoom(); @@ -52,39 +54,25 @@ export async function ensureLobbyRoomExists(client: MatrixClient): Promise { + await fs.copyFile(path.join(__dirname, 'config', 'harness.yaml'), path.join(__dirname, '../../config/harness.yaml')); + await registerUser('mjolnir', 'mjolnir', 'mjolnir', true).catch(e => { if (e.isAxiosError && e.response.data.errcode === 'M_USER_IN_USE') { - console.log(`${username} already registered, skipping`) + console.log('mjolnir already registered, skipping'); } else { throw e; } }); } +// it actually might make sense to give mjolnir a clean plate each time we setup and teardown a test. +// the only issues with this might be e.g. if we need to delete a community or something +// that mjolnir sets up each time, but tbh we should probably just avoid setting things like that and tearing it down. +// One thing that probably should not be persisted between tests is the management room, subscribed lists and protected rooms. +export async function makeMjolnir() { + await configureMjolnir(); + console.info('starting mjolnir'); + const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()); + const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password); + await ensureManagementRoomExists(client); + return await setupMjolnir(client, config); +} \ No newline at end of file