Merge branch 'main' into gnuxie/why-are-they-banned

This commit is contained in:
gnuxie 2022-07-06 15:29:21 +01:00
commit c581d0e2ff
16 changed files with 389 additions and 68 deletions

View File

@ -11,10 +11,38 @@ env:
jobs: jobs:
build: build:
name: Integration tests name: Build & Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Specifically use node 16 like in the readme.
uses: actions/setup-node@v3
with:
node-version: '16'
- run: yarn install
- run: yarn build
- run: yarn lint
unit:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Specifically use node 16 like in the readme.
uses: actions/setup-node@v3
with:
node-version: '16'
- run: yarn install
- run: yarn test
integration:
name: Integration tests
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
- name: install mx-tester - name: install mx-tester
run: cargo install mx-tester run: cargo install mx-tester
- name: Setup image - name: Setup image

View File

@ -1,4 +1,4 @@
FROM node:14-alpine FROM node:16-alpine
COPY . /tmp/src COPY . /tmp/src
RUN cd /tmp/src \ RUN cd /tmp/src \
&& yarn install \ && yarn install \

View File

@ -235,3 +235,8 @@ web:
abuseReporting: abuseReporting:
# Whether to enable this feature. # Whether to enable this feature.
enabled: false enabled: false
# Whether or not to actively poll synapse for abuse reports, to be used
# instead of intercepting client calls to synapse's abuse endpoint, when that
# isn't possible/practical.
pollReports: false

View File

@ -1,4 +1,4 @@
To build mjolnir, you have to have installed `yarn` 1.x and Node 14. To build mjolnir, you have to have installed `yarn` 1.x and Node 16.
```bash ```bash
git clone https://github.com/matrix-org/mjolnir.git git clone https://github.com/matrix-org/mjolnir.git

View File

@ -13,7 +13,7 @@
"lint": "tslint --project ./tsconfig.json -t stylish", "lint": "tslint --project ./tsconfig.json -t stylish",
"start:dev": "yarn build && node --async-stack-traces lib/index.js", "start:dev": "yarn build && node --async-stack-traces lib/index.js",
"test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts", "test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts",
"test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --project ./tsconfig.json \"test/integration/**/*Test.ts\"", "test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",
"test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts", "test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts",
"version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py" "version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py"
}, },
@ -48,6 +48,6 @@
"shell-quote": "^1.7.3" "shell-quote": "^1.7.3"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=16.0.0"
} }
} }

View File

@ -43,6 +43,7 @@ import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { htmlEscape } from "./utils"; import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager"; import { ReportManager } from "./report/ReportManager";
import { ReportPoller } from "./report/ReportPoller";
import { WebAPIs } from "./webapis/WebAPIs"; import { WebAPIs } from "./webapis/WebAPIs";
import { replaceRoomIdsWithPills } from "./utils"; import { replaceRoomIdsWithPills } from "./utils";
import RuleServer from "./models/RuleServer"; import RuleServer from "./models/RuleServer";
@ -67,6 +68,11 @@ const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for."; const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for.";
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence"; const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
/**
* Synapse will tell us where we last got to on polling reports, so we need
* to store that for pagination on further polls
*/
export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll";
export class Mjolnir { export class Mjolnir {
private displayName: string; private displayName: string;
@ -97,7 +103,10 @@ export class Mjolnir {
private webapis: WebAPIs; private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker; private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
public taskQueue: ThrottlingQueue; public taskQueue: ThrottlingQueue;
/*
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
*/
private reportPoller?: ReportPoller;
/** /**
* Adds a listener to the client that will automatically accept invitations. * Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client * @param {MatrixClient} client
@ -256,12 +265,13 @@ export class Mjolnir {
// Setup Web APIs // Setup Web APIs
console.log("Creating Web APIs"); console.log("Creating Web APIs");
const reportManager = new ReportManager(this); const reportManager = new ReportManager(this);
reportManager.on("report.new", this.handleReport); reportManager.on("report.new", this.handleReport.bind(this));
this.webapis = new WebAPIs(reportManager, this.ruleServer); this.webapis = new WebAPIs(reportManager, this.ruleServer);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, reportManager);
}
// Setup join/leave listener // Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client); this.roomJoins = new RoomMemberManager(this.client);
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
} }
@ -302,6 +312,20 @@ export class Mjolnir {
console.log("Starting web server"); console.log("Starting web server");
await this.webapis.start(); await this.webapis.start();
if (this.reportPoller) {
let reportPollSetting: { from: number } = { from: 0 };
try {
reportPollSetting = await this.client.getAccountData(REPORT_POLL_EVENT_TYPE);
} catch (err) {
if (err.body?.errcode !== "M_NOT_FOUND") {
throw err;
} else {
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
}
}
this.reportPoller.start(reportPollSetting.from);
}
// Load the state. // Load the state.
this.currentState = STATE_CHECKING_PERMISSIONS; this.currentState = STATE_CHECKING_PERMISSIONS;
@ -358,6 +382,7 @@ export class Mjolnir {
LogService.info("Mjolnir", "Stopping Mjolnir..."); LogService.info("Mjolnir", "Stopping Mjolnir...");
this.client.stop(); this.client.stop();
this.webapis.stop(); this.webapis.stop();
this.reportPoller?.stop();
} }
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> { public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
@ -1163,7 +1188,7 @@ export class Mjolnir {
return await this.eventRedactionQueue.process(this, roomId); return await this.eventRedactionQueue.process(this, roomId);
} }
private async handleReport(roomId: string, reporterId: string, event: any, reason?: string) { private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
for (const protection of this.enabledProtections) { for (const protection of this.enabledProtections) {
await protection.handleReport(this, roomId, reporterId, event, reason); await protection.handleReport(this, roomId, reporterId, event, reason);
} }

View File

@ -133,7 +133,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir unban <list shortcode> <user|room|server> <glob> [apply] - Removes an entity from the ban list. If apply is 'true', the users matching the glob will actually be unbanned\n" + "!mjolnir unban <list shortcode> <user|room|server> <glob> [apply] - Removes an entity from the ban list. If apply is 'true', the users matching the glob will actually be unbanned\n" +
"!mjolnir redact <user ID> [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" + "!mjolnir redact <user ID> [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" +
"!mjolnir redact <event permalink> - Redacts a message by permalink\n" + "!mjolnir redact <event permalink> - Redacts a message by permalink\n" +
"!mjolnir kick <user ID> [room alias/ID] [reason] - Kicks a user in a particular room or all protected rooms\n" + "!mjolnir kick <glob> [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" +
"!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" +
"!mjolnir rules matching <user|room|server> - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user." + "!mjolnir rules matching <user|room|server> - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user." +
"!mjolnir sync - Force updates of all lists and re-apply rules\n" + "!mjolnir sync - Force updates of all lists and re-apply rules\n" +

View File

@ -15,14 +15,31 @@ limitations under the License.
*/ */
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel } from "matrix-bot-sdk"; import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import config from "../config"; import config from "../config";
// !mjolnir kick <user|filter> [room] [reason] // !mjolnir kick <user|filter> [room] [reason]
export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const userId = parts[2]; let force = false;
const glob = parts[2];
let rooms = [...Object.keys(mjolnir.protectedRooms)]; let rooms = [...Object.keys(mjolnir.protectedRooms)];
if (parts[parts.length - 1] === "--force") {
force = true;
parts.pop();
}
if (config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) {
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
return;
}
const kickRule = new MatrixGlob(glob);
let reason: string | undefined; let reason: string | undefined;
if (parts.length > 3) { if (parts.length > 3) {
let reasonIndex = 3; let reasonIndex = 3;
@ -32,19 +49,29 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
} }
reason = parts.slice(reasonIndex).join(' ') || '<no reason supplied>'; reason = parts.slice(reasonIndex).join(' ') || '<no reason supplied>';
} }
if (!reason) reason = "<none supplied>"; if (!reason) reason = '<none supplied>';
for (const targetRoomId of rooms) { for (const protectedRoomId of rooms) {
const joinedUsers = await mjolnir.client.getJoinedRoomMembers(targetRoomId); const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
if (!joinedUsers.includes(userId)) continue; // skip
for (const member of members) {
const victim = member.membershipFor;
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
await mjolnir.logMessage(LogLevel.INFO, "KickCommand", `Kicking ${userId} in ${targetRoomId} for ${reason}`, targetRoomId);
if (!config.noop) { if (!config.noop) {
try {
await mjolnir.taskQueue.push(async () => { await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(userId, targetRoomId, reason); return mjolnir.client.kickUser(victim, protectedRoomId, reason);
}); });
} catch (e) {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
}
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${userId} in ${targetRoomId} but the bot is running in no-op mode.`, targetRoomId); await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
}
}
} }
} }

View File

@ -53,6 +53,7 @@ interface IConfig {
* of one background task and the start of the next one. * of one background task and the start of the next one.
*/ */
backgroundDelayMS: number; backgroundDelayMS: number;
pollReports: boolean;
admin?: { admin?: {
enableMakeRoomAdminCommand?: boolean; enableMakeRoomAdminCommand?: boolean;
} }
@ -122,6 +123,7 @@ const defaultConfig: IConfig = {
automaticallyRedactForReasons: ["spam", "advertising"], automaticallyRedactForReasons: ["spam", "advertising"],
protectAllJoinedRooms: false, protectAllJoinedRooms: false,
backgroundDelayMS: 500, backgroundDelayMS: 500,
pollReports: false,
commands: { commands: {
allowNoPrefix: false, allowNoPrefix: false,
additionalPrefixes: [], additionalPrefixes: [],

145
src/report/ReportPoller.ts Normal file
View File

@ -0,0 +1,145 @@
/*
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 { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir";
import { ReportManager } from './ReportManager';
import { LogLevel } from "matrix-bot-sdk";
class InvalidStateError extends Error {}
/**
* A class to poll synapse's report endpoint, so we can act on new reports
*
* @param mjolnir The running Mjolnir instance
* @param manager The report manager in to which we feed new reports
*/
export class ReportPoller {
/**
* https://matrix-org.github.io/synapse/latest/admin_api/event_reports.html
* "from" is an opaque token that is returned from the API to paginate reports
*/
private from = 0;
/**
* The currently-pending report poll
*/
private timeout: ReturnType<typeof setTimeout> | null = null;
constructor(
private mjolnir: Mjolnir,
private manager: ReportManager,
) { }
private schedulePoll() {
if (this.timeout === null) {
/*
* Important that we use `setTimeout` here, not `setInterval`,
* because if there's networking problems and `getAbuseReports`
* hangs for longer thank the interval, it could cause a stampede
* of requests when networking problems resolve
*/
this.timeout = setTimeout(
this.tryGetAbuseReports.bind(this),
30_000 // a minute in milliseconds
);
} else {
throw new InvalidStateError("poll already scheduled");
}
}
private async getAbuseReports() {
let response_: {
event_reports: { room_id: string, event_id: string, sender: string, reason: string }[],
next_token: number | undefined
} | undefined;
try {
response_ = await this.mjolnir.client.doRequest(
"GET",
"/_synapse/admin/v1/event_reports",
{ from: this.from.toString() }
);
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
return;
}
const response = response_!;
for (let report of response.event_reports) {
if (!(report.room_id in this.mjolnir.protectedRooms)) {
continue;
}
let event: any; // `any` because `handleServerAbuseReport` uses `any`
try {
event = (await this.mjolnir.client.doRequest(
"GET",
`/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1`
)).event;
} catch (ex) {
this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`);
continue;
}
await this.manager.handleServerAbuseReport({
roomId: report.room_id,
reporterId: report.sender,
event: event,
reason: report.reason,
});
}
/*
* This API endpoint returns an opaque `next_token` number that we
* need to give back to subsequent requests for pagination, so here we
* save it in account data
*/
if (response.next_token !== undefined) {
this.from = response.next_token;
try {
await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token });
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
}
}
}
private async tryGetAbuseReports() {
this.timeout = null;
try {
await this.getAbuseReports()
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`);
}
this.schedulePoll();
}
public start(startFrom: number) {
if (this.timeout === null) {
this.from = startFrom;
this.schedulePoll();
} else {
throw new InvalidStateError("cannot start an already started poll");
}
}
public stop() {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
} else {
throw new InvalidStateError("cannot stop a poll that hasn't started");
}
}
}

View File

@ -386,6 +386,9 @@ function patchMatrixClientForRetry() {
// We need to retry. // We need to retry.
reject(err); reject(err);
} else { } else {
if (attempt >= MAX_REQUEST_ATTEMPTS) {
LogService.warn('Mjolnir.client', `Retried request ${params.method} ${params.uri} ${attempt} times, giving up.`);
}
// No need-to-retry error? Lucky us! // No need-to-retry error? Lucky us!
// Note that this may very well be an error, just not // Note that this may very well be an error, just not
// one we need to retry. // one we need to retry.

View File

@ -231,7 +231,6 @@ describe('Test: We will not be able to ban ourselves via ACL.', function () {
describe('Test: ACL updates will batch when rules are added in succession.', function () { describe('Test: ACL updates will batch when rules are added in succession.', function () {
it('Will batch ACL updates if we spam rules into a BanList', async function () { it('Will batch ACL updates if we spam rules into a BanList', async function () {
this.timeout(180000)
const mjolnir = config.RUNTIME.client! const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" }});
@ -268,6 +267,8 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
// Give them a bit of a spread over time. // Give them a bit of a spread over time.
await new Promise(resolve => setTimeout(resolve, 5)); await new Promise(resolve => setTimeout(resolve, 5));
} }
// give the events a chance to appear in the response to `/state`, since this is a problem.
await new Promise(resolve => setTimeout(resolve, 2000));
// We do this because it should force us to wait until all the ACL events have been applied. // We do this because it should force us to wait until all the ACL events have been applied.
// Even if that does mean the last few events will not go through batching... // Even if that does mean the last few events will not go through batching...
@ -364,9 +365,9 @@ describe('Test: unbaning entities via the BanList.', function () {
}) })
}) })
describe.only('Test: should apply bans to the most recently active rooms first', function () { describe('Test: should apply bans to the most recently active rooms first', function () {
it('Applies bans to the most recently active rooms first', async function () { it('Applies bans to the most recently active rooms first', async function () {
this.timeout(6000000000) this.timeout(180000)
const mjolnir = config.RUNTIME.client! const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" }});

View File

@ -0,0 +1,47 @@
import { Mjolnir } from "../../src/Mjolnir";
import { IProtection } from "../../src/protections/IProtection";
import { newTestUser } from "./clientHelper";
describe("Test: Report polling", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser({ name: { contains: "protection-settings" }});
})
it("Mjolnir correctly retrieves a report from synapse", async function() {
this.timeout(40000);
let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await client.getUserId()] });
await client.joinRoom(protectedRoomId);
await this.mjolnir.addProtectedRoom(protectedRoomId);
const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"});
await new Promise(async resolve => {
await this.mjolnir.registerProtection(new class implements IProtection {
name = "jYvufI";
description = "A test protection";
settings = { };
handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { };
handleReport = (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) => {
if (reason === "x5h1Je") {
resolve(null);
}
};
});
await this.mjolnir.enableProtection("jYvufI");
await client.doRequest(
"POST",
`/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", {
reason: "x5h1Je"
}
);
});
// So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26)
// because the promise above gets resolved before we finish awaiting the report sending request on L#31,
// then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond.
// Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling.
// Ok, well apparently that needs a big refactor to change, but if you change the config before running this test,
// then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326.
await new Promise(resolve => setTimeout(resolve, 1000));
});
});

View File

@ -401,25 +401,37 @@ describe("Test: Testing RoomMemberManager", function() {
} }
// Create and protect rooms. // Create and protect rooms.
// - room 0 remains unprotected, as witness; //
// - room 1 is protected but won't be targeted directly, also as witness. // We reserve two control rooms:
// - room 0, also known as the "control unprotected room" is unprotected
// (we're not calling `!mjolnir rooms add` for this room), so none
// of the operations of `!mjolnir since` shoud affect it. We are
// using it to control, at the end of each experiment, that none of
// the `!mjolnir since` operations affect it.
// - room 1, also known as the "control protected room" is protected
// (we are calling `!mjolnir rooms add` for this room), but we are
// never directly requesting any `!mjolnir since` action against
// this room. We are using it to control, at the end of each experiment,
// that none of the `!mjolnir since` operations that should target
// one single other room also affect that room. It is, however, affected
// by general operations that are designed to affect all protected rooms.
const NUMBER_OF_ROOMS = 18; const NUMBER_OF_ROOMS = 18;
const roomIds: string[] = []; const allRoomIds: string[] = [];
const roomAliases: string[] = []; const allRoomAliases: string[] = [];
const mjolnirUserId = await this.mjolnir.client.getUserId(); const mjolnirUserId = await this.mjolnir.client.getUserId();
for (let i = 0; i < NUMBER_OF_ROOMS; ++i) { for (let i = 0; i < NUMBER_OF_ROOMS; ++i) {
const roomId = await this.moderator.createRoom({ const roomId = await this.moderator.createRoom({
invite: [mjolnirUserId, ...goodUserIds, ...badUserIds], invite: [mjolnirUserId, ...goodUserIds, ...badUserIds],
}); });
roomIds.push(roomId); allRoomIds.push(roomId);
const alias = `#since-test-${randomUUID()}:localhost:9999`; const alias = `#since-test-${randomUUID()}:localhost:9999`;
await this.moderator.createRoomAlias(alias, roomId); await this.moderator.createRoomAlias(alias, roomId);
roomAliases.push(alias); allRoomAliases.push(alias);
} }
for (let i = 1; i < roomIds.length; ++i) { for (let i = 1; i < allRoomIds.length; ++i) {
// Protect all rooms except roomIds[0], as witness. // Protect all rooms except allRoomIds[0], as control.
const roomId = roomIds[i]; const roomId = allRoomIds[i];
await this.mjolnir.client.joinRoom(roomId); await this.mjolnir.client.joinRoom(roomId);
await this.moderator.setUserPowerLevel(mjolnirUserId, roomId, 100); await this.moderator.setUserPowerLevel(mjolnirUserId, roomId, 100);
await this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${roomId}` }); await this.moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${roomId}` });
@ -429,8 +441,8 @@ describe("Test: Testing RoomMemberManager", function() {
do { do {
let protectedRooms = this.mjolnir.protectedRooms; let protectedRooms = this.mjolnir.protectedRooms;
protectedRoomsUpdated = true; protectedRoomsUpdated = true;
for (let i = 1; i < roomIds.length; ++i) { for (let i = 1; i < allRoomIds.length; ++i) {
const roomId = roomIds[i]; const roomId = allRoomIds[i];
if (!(roomId in protectedRooms)) { if (!(roomId in protectedRooms)) {
protectedRoomsUpdated = false; protectedRoomsUpdated = false;
await new Promise(resolve => setTimeout(resolve, 1_000)); await new Promise(resolve => setTimeout(resolve, 1_000));
@ -440,7 +452,7 @@ describe("Test: Testing RoomMemberManager", function() {
// Good users join before cut date. // Good users join before cut date.
for (let user of this.goodUsers) { for (let user of this.goodUsers) {
for (let roomId of roomIds) { for (let roomId of allRoomIds) {
await user.joinRoom(roomId); await user.joinRoom(roomId);
} }
} }
@ -453,25 +465,30 @@ describe("Test: Testing RoomMemberManager", function() {
// Bad users join after cut date. // Bad users join after cut date.
for (let user of this.badUsers) { for (let user of this.badUsers) {
for (let roomId of roomIds) { for (let roomId of allRoomIds) {
await user.joinRoom(roomId); await user.joinRoom(roomId);
} }
} }
// Finally, prepare our control rooms and separate them
// from the regular rooms.
const CONTROL_UNPROTECTED_ROOM_ID = allRoomIds[0];
const CONTROL_PROTECTED_ID = allRoomIds[1];
const roomIds = allRoomIds.slice(2);
const roomAliases = allRoomAliases.slice(2);
enum Method { enum Method {
kick, kick,
ban, ban,
mute, mute,
unmute, unmute,
} }
const WITNESS_UNPROTECTED_ROOM_ID = roomIds[0];
const WITNESS_ROOM_ID = roomIds[1];
class Experiment { class Experiment {
// A human-readable name for the command. // A human-readable name for the command.
readonly name: string; readonly name: string;
// If `true`, this command should affect room `WITNESS_ROOM_ID`. // If `true`, this command should affect room `CONTROL_PROTECTED_ID`.
// Defaults to `false`. // Defaults to `false`.
readonly shouldAffectWitnessRoom: boolean; readonly shouldAffectControlProtected: boolean;
// The actual command-line. // The actual command-line.
readonly command: (roomId: string, roomAlias: string) => string; readonly command: (roomId: string, roomAlias: string) => string;
// The number of responses we expect to this command. // The number of responses we expect to this command.
@ -484,17 +501,23 @@ describe("Test: Testing RoomMemberManager", function() {
// Defaults to `false`. // Defaults to `false`.
readonly isSameRoomAsPrevious: boolean; readonly isSameRoomAsPrevious: boolean;
// The index of the room on which we're acting.
//
// Initialized by `addTo`.
roomIndex: number | undefined; roomIndex: number | undefined;
constructor({name, shouldAffectWitnessRoom, command, n, method, sameRoom}: {name: string, command: (roomId: string, roomAlias: string) => string, shouldAffectWitnessRoom?: boolean, n?: number, method: Method, sameRoom?: boolean}) { constructor({name, shouldAffectControlProtected, command, n, method, sameRoom}: {name: string, command: (roomId: string, roomAlias: string) => string, shouldAffectControlProtected?: boolean, n?: number, method: Method, sameRoom?: boolean}) {
this.name = name; this.name = name;
this.shouldAffectWitnessRoom = typeof shouldAffectWitnessRoom === "undefined" ? false : shouldAffectWitnessRoom; this.shouldAffectControlProtected = typeof shouldAffectControlProtected === "undefined" ? false : shouldAffectControlProtected;
this.command = command; this.command = command;
this.n = typeof n === "undefined" ? 1 : n; this.n = typeof n === "undefined" ? 1 : n;
this.method = method; this.method = method;
this.isSameRoomAsPrevious = typeof sameRoom === "undefined" ? false : sameRoom; this.isSameRoomAsPrevious = typeof sameRoom === "undefined" ? false : sameRoom;
} }
// Add an experiment to the list of experiments.
//
// This is how `roomIndex` gets initialized.
addTo(experiments: Experiment[]) { addTo(experiments: Experiment[]) {
if (this.isSameRoomAsPrevious) { if (this.isSameRoomAsPrevious) {
this.roomIndex = experiments[experiments.length - 1].roomIndex; this.roomIndex = experiments[experiments.length - 1].roomIndex;
@ -586,7 +609,7 @@ describe("Test: Testing RoomMemberManager", function() {
new Experiment({ new Experiment({
name: "kick with date and reason", name: "kick with date and reason",
command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId} bad, bad user`, command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId} bad, bad user`,
shouldAffectWitnessRoom: false, shouldAffectControlProtected: false,
n: 1, n: 1,
method: Method.kick, method: Method.kick,
}), }),
@ -626,19 +649,35 @@ describe("Test: Testing RoomMemberManager", function() {
new Experiment({ new Experiment({
name: "kick with date everywhere", name: "kick with date everywhere",
command: () => `!mjolnir since "${cutDate}" kick 100 * bad, bad user`, command: () => `!mjolnir since "${cutDate}" kick 100 * bad, bad user`,
shouldAffectWitnessRoom: true, shouldAffectControlProtected: true,
n: NUMBER_OF_ROOMS - 1, n: NUMBER_OF_ROOMS - 1,
method: Method.kick, method: Method.kick,
}), }),
]) { ]) {
experiment.addTo(EXPERIMENTS); experiment.addTo(EXPERIMENTS);
} }
// Just-in-case health check, before starting.
{
const usersInUnprotectedControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID);
const usersInControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_PROTECTED_ID);
for (let userId of goodUserIds) {
assert.ok(usersInUnprotectedControlProtected.includes(userId), `Initially, good user ${userId} should be in the unprotected control room`);
assert.ok(usersInControlProtected.includes(userId), `Initially, good user ${userId} should be in the control room`);
}
for (let userId of badUserIds) {
assert.ok(usersInUnprotectedControlProtected.includes(userId), `Initially, bad user ${userId} should be in the unprotected control room`);
assert.ok(usersInControlProtected.includes(userId), `Initially, bad user ${userId} should be in the control room`);
}
}
for (let i = 0; i < EXPERIMENTS.length; ++i) { for (let i = 0; i < EXPERIMENTS.length; ++i) {
const experiment = EXPERIMENTS[i]; const experiment = EXPERIMENTS[i];
const index = experiment.roomIndex! + 1; const index = experiment.roomIndex!;
const roomId = roomIds[index]; const roomId = roomIds[index];
const roomAlias = roomAliases[index]; const roomAlias = roomAliases[index];
const joined = this.mjolnir.roomJoins.getUsersInRoom(roomId, start, 100); const joined = this.mjolnir.roomJoins.getUsersInRoom(roomId, start, 100);
console.debug(`Running experiment ${i} "${experiment.name}" in room index ${index} (${roomId} / ${roomAlias}): \`${experiment.command(roomId, roomAlias)}\``);
assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`); assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`);
// Run experiment. // Run experiment.
@ -650,12 +689,12 @@ describe("Test: Testing RoomMemberManager", function() {
// Check post-conditions. // Check post-conditions.
const usersInRoom = await this.mjolnir.client.getJoinedRoomMembers(roomId); const usersInRoom = await this.mjolnir.client.getJoinedRoomMembers(roomId);
const usersInUnprotectedWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_UNPROTECTED_ROOM_ID); const usersInUnprotectedControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_UNPROTECTED_ROOM_ID);
const usersInWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_ROOM_ID); const usersInControlProtected = await this.mjolnir.client.getJoinedRoomMembers(CONTROL_PROTECTED_ID);
for (let userId of goodUserIds) { for (let userId of goodUserIds) {
assert.ok(usersInRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in affected room`); assert.ok(usersInRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in affected room`);
assert.ok(usersInWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in witness room`); assert.ok(usersInControlProtected.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in control room (${CONTROL_PROTECTED_ID})`);
assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected witness room`); assert.ok(usersInUnprotectedControlProtected.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected control room (${CONTROL_UNPROTECTED_ROOM_ID})`);
} }
if (experiment.method === Method.mute) { if (experiment.method === Method.mute) {
for (let userId of goodUserIds) { for (let userId of goodUserIds) {
@ -678,8 +717,8 @@ describe("Test: Testing RoomMemberManager", function() {
} else { } else {
for (let userId of badUserIds) { for (let userId of badUserIds) {
assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`); assert.ok(!usersInRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should NOT be in affected room`);
assert.equal(usersInWitnessRoom.includes(userId), !experiment.shouldAffectWitnessRoom, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectWitnessRoom ? "NOT" : "still"} be in witness room`); assert.equal(usersInControlProtected.includes(userId), !experiment.shouldAffectControlProtected, `After a ${experiment.name}, bad user ${userId} should ${experiment.shouldAffectControlProtected ? "NOT" : "still"} be in control room`);
assert.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected witness room`); assert.ok(usersInUnprotectedControlProtected.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected control room`);
const leaveEvent = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId); const leaveEvent = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.member", userId);
switch (experiment.method) { switch (experiment.method) {
case Method.kick: case Method.kick:

View File

@ -5,17 +5,16 @@ import { getFirstReaction } from "./commands/commandUtils";
describe("Test: throttled users can function with Mjolnir.", function () { describe("Test: throttled users can function with Mjolnir.", function () {
it('throttled users survive being throttled by synapse', async function() { it('throttled users survive being throttled by synapse', async function() {
this.timeout(60000);
let throttledUser = await newTestUser({ name: { contains: "throttled" }, isThrottled: true }); let throttledUser = await newTestUser({ name: { contains: "throttled" }, isThrottled: true });
let throttledUserId = await throttledUser.getUserId(); let throttledUserId = await throttledUser.getUserId();
let targetRoom = await throttledUser.createRoom(); let targetRoom = await throttledUser.createRoom();
// send enough messages to hit the rate limit. // send enough messages to hit the rate limit.
await Promise.all([...Array(150).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`}))); await Promise.all([...Array(25).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`})));
let messageCount = 0; let messageCount = 0;
await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 150, (events) => { await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 25, (events) => {
messageCount += events.length; messageCount += events.length;
}); });
assert.equal(messageCount, 150, "There should have been 150 messages in this room"); assert.equal(messageCount, 25, "There should have been 25 messages in this room");
}) })
}) })
@ -31,7 +30,6 @@ describe("Test: Mjolnir can still sync and respond to commands while throttled",
}) })
it('Can still perform and respond to a redaction command', async function () { it('Can still perform and respond to a redaction command', async function () {
this.timeout(60000);
// Create a few users and a room. // Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } }); let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
let badUserId = await badUser.getUserId(); let badUserId = await badUser.getUserId();
@ -45,12 +43,12 @@ describe("Test: Mjolnir can still sync and respond to commands while throttled",
await badUser.joinRoom(targetRoom); await badUser.joinRoom(targetRoom);
// Give Mjolnir some work to do and some messages to sync through. // Give Mjolnir some work to do and some messages to sync through.
await Promise.all([...Array(100).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`}))); await Promise.all([...Array(25).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
await Promise.all([...Array(50).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'}))); await Promise.all([...Array(25).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'})));
await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms add ${targetRoom}`}); await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms add ${targetRoom}`});
await Promise.all([...Array(50).keys()].map((i) => badUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Bad Message #${i}`}))); await Promise.all([...Array(25).keys()].map((i) => badUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Bad Message #${i}`})));
try { try {
await moderator.start(); await moderator.start();
@ -72,6 +70,6 @@ describe("Test: Mjolnir can still sync and respond to commands while throttled",
} }
}) })
}); });
assert.equal(count, 51, "There should be exactly 51 events from the spammer in this room."); assert.equal(count, 26, "There should be exactly 26 events from the spammer in this room.");
}) })
}) })

View File

@ -22,6 +22,7 @@
"./src/**/*", "./src/**/*",
"./test/integration/manualLaunchScript.ts", "./test/integration/manualLaunchScript.ts",
"./test/integration/roomMembersTest.ts", "./test/integration/roomMembersTest.ts",
"./test/integration/banListTest.ts" "./test/integration/banListTest.ts",
"./test/integration/reportPollingTest"
] ]
} }