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:
build:
name: Integration tests
name: Build & Lint
runs-on: ubuntu-latest
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
run: cargo install mx-tester
- name: Setup image

View File

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

View File

@ -235,3 +235,8 @@ web:
abuseReporting:
# Whether to enable this feature.
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
git clone https://github.com/matrix-org/mjolnir.git
@ -12,4 +12,4 @@ cp config/default.yaml config/development.yaml
nano config/development.yaml
node lib/index.js
```
```

View File

@ -13,7 +13,7 @@
"lint": "tslint --project ./tsconfig.json -t stylish",
"start:dev": "yarn build && node --async-stack-traces lib/index.js",
"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",
"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"
},
"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 { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager";
import { ReportPoller } from "./report/ReportPoller";
import { WebAPIs } from "./webapis/WebAPIs";
import { replaceRoomIdsWithPills } from "./utils";
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 WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for.";
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 {
private displayName: string;
@ -97,7 +103,10 @@ export class Mjolnir {
private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
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.
* @param {MatrixClient} client
@ -256,12 +265,13 @@ export class Mjolnir {
// Setup Web APIs
console.log("Creating Web APIs");
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);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, reportManager);
}
// Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client);
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
}
@ -302,6 +312,20 @@ export class Mjolnir {
console.log("Starting web server");
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.
this.currentState = STATE_CHECKING_PERMISSIONS;
@ -358,6 +382,7 @@ export class Mjolnir {
LogService.info("Mjolnir", "Stopping Mjolnir...");
this.client.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> {
@ -1163,7 +1188,7 @@ export class Mjolnir {
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) {
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 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 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 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" +

View File

@ -15,14 +15,31 @@ limitations under the License.
*/
import { Mjolnir } from "../Mjolnir";
import { LogLevel } from "matrix-bot-sdk";
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import config from "../config";
// !mjolnir kick <user|filter> [room] [reason]
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)];
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;
if (parts.length > 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>';
}
if (!reason) reason = "<none supplied>";
if (!reason) reason = '<none supplied>';
for (const targetRoomId of rooms) {
const joinedUsers = await mjolnir.client.getJoinedRoomMembers(targetRoomId);
if (!joinedUsers.includes(userId)) continue; // skip
for (const protectedRoomId of rooms) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ["join"], ["ban", "leave"]);
await mjolnir.logMessage(LogLevel.INFO, "KickCommand", `Kicking ${userId} in ${targetRoomId} for ${reason}`, targetRoomId);
if (!config.noop) {
await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(userId, targetRoomId, reason);
});
} else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${userId} in ${targetRoomId} but the bot is running in no-op mode.`, targetRoomId);
for (const member of members) {
const victim = member.membershipFor;
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) {
try {
await mjolnir.taskQueue.push(async () => {
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 {
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.
*/
backgroundDelayMS: number;
pollReports: boolean;
admin?: {
enableMakeRoomAdminCommand?: boolean;
}
@ -122,6 +123,7 @@ const defaultConfig: IConfig = {
automaticallyRedactForReasons: ["spam", "advertising"],
protectAllJoinedRooms: false,
backgroundDelayMS: 500,
pollReports: false,
commands: {
allowNoPrefix: false,
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.
reject(err);
} 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!
// Note that this may very well be an error, just not
// 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 () {
it('Will batch ACL updates if we spam rules into a BanList', async function () {
this.timeout(180000)
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
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.
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.
// 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 () {
this.timeout(6000000000)
this.timeout(180000)
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
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.
// - 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 roomIds: string[] = [];
const roomAliases: string[] = [];
const allRoomIds: string[] = [];
const allRoomAliases: string[] = [];
const mjolnirUserId = await this.mjolnir.client.getUserId();
for (let i = 0; i < NUMBER_OF_ROOMS; ++i) {
const roomId = await this.moderator.createRoom({
invite: [mjolnirUserId, ...goodUserIds, ...badUserIds],
});
roomIds.push(roomId);
allRoomIds.push(roomId);
const alias = `#since-test-${randomUUID()}:localhost:9999`;
await this.moderator.createRoomAlias(alias, roomId);
roomAliases.push(alias);
allRoomAliases.push(alias);
}
for (let i = 1; i < roomIds.length; ++i) {
// Protect all rooms except roomIds[0], as witness.
const roomId = roomIds[i];
for (let i = 1; i < allRoomIds.length; ++i) {
// Protect all rooms except allRoomIds[0], as control.
const roomId = allRoomIds[i];
await this.mjolnir.client.joinRoom(roomId);
await this.moderator.setUserPowerLevel(mjolnirUserId, roomId, 100);
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 {
let protectedRooms = this.mjolnir.protectedRooms;
protectedRoomsUpdated = true;
for (let i = 1; i < roomIds.length; ++i) {
const roomId = roomIds[i];
for (let i = 1; i < allRoomIds.length; ++i) {
const roomId = allRoomIds[i];
if (!(roomId in protectedRooms)) {
protectedRoomsUpdated = false;
await new Promise(resolve => setTimeout(resolve, 1_000));
@ -440,7 +452,7 @@ describe("Test: Testing RoomMemberManager", function() {
// Good users join before cut date.
for (let user of this.goodUsers) {
for (let roomId of roomIds) {
for (let roomId of allRoomIds) {
await user.joinRoom(roomId);
}
}
@ -453,25 +465,30 @@ describe("Test: Testing RoomMemberManager", function() {
// Bad users join after cut date.
for (let user of this.badUsers) {
for (let roomId of roomIds) {
for (let roomId of allRoomIds) {
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 {
kick,
ban,
mute,
unmute,
}
const WITNESS_UNPROTECTED_ROOM_ID = roomIds[0];
const WITNESS_ROOM_ID = roomIds[1];
class Experiment {
// A human-readable name for the command.
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`.
readonly shouldAffectWitnessRoom: boolean;
readonly shouldAffectControlProtected: boolean;
// The actual command-line.
readonly command: (roomId: string, roomAlias: string) => string;
// The number of responses we expect to this command.
@ -484,17 +501,23 @@ describe("Test: Testing RoomMemberManager", function() {
// Defaults to `false`.
readonly isSameRoomAsPrevious: boolean;
// The index of the room on which we're acting.
//
// Initialized by `addTo`.
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.shouldAffectWitnessRoom = typeof shouldAffectWitnessRoom === "undefined" ? false : shouldAffectWitnessRoom;
this.shouldAffectControlProtected = typeof shouldAffectControlProtected === "undefined" ? false : shouldAffectControlProtected;
this.command = command;
this.n = typeof n === "undefined" ? 1 : n;
this.method = method;
this.isSameRoomAsPrevious = typeof sameRoom === "undefined" ? false : sameRoom;
}
// Add an experiment to the list of experiments.
//
// This is how `roomIndex` gets initialized.
addTo(experiments: Experiment[]) {
if (this.isSameRoomAsPrevious) {
this.roomIndex = experiments[experiments.length - 1].roomIndex;
@ -586,7 +609,7 @@ describe("Test: Testing RoomMemberManager", function() {
new Experiment({
name: "kick with date and reason",
command: (roomId: string) => `!mjolnir since "${cutDate}" kick 100 ${roomId} bad, bad user`,
shouldAffectWitnessRoom: false,
shouldAffectControlProtected: false,
n: 1,
method: Method.kick,
}),
@ -626,19 +649,35 @@ describe("Test: Testing RoomMemberManager", function() {
new Experiment({
name: "kick with date everywhere",
command: () => `!mjolnir since "${cutDate}" kick 100 * bad, bad user`,
shouldAffectWitnessRoom: true,
shouldAffectControlProtected: true,
n: NUMBER_OF_ROOMS - 1,
method: Method.kick,
}),
]) {
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) {
const experiment = EXPERIMENTS[i];
const index = experiment.roomIndex! + 1;
const roomId = roomIds[index];
const index = experiment.roomIndex!;
const roomId = roomIds[index];
const roomAlias = roomAliases[index];
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}`);
// Run experiment.
@ -650,12 +689,12 @@ describe("Test: Testing RoomMemberManager", function() {
// Check post-conditions.
const usersInRoom = await this.mjolnir.client.getJoinedRoomMembers(roomId);
const usersInUnprotectedWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_UNPROTECTED_ROOM_ID);
const usersInWitnessRoom = await this.mjolnir.client.getJoinedRoomMembers(WITNESS_ROOM_ID);
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(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(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, good user ${userId} should still be in unprotected 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(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) {
for (let userId of goodUserIds) {
@ -678,8 +717,8 @@ describe("Test: Testing RoomMemberManager", function() {
} else {
for (let userId of badUserIds) {
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.ok(usersInUnprotectedWitnessRoom.includes(userId), `After a ${experiment.name}, bad user ${userId} should still be in unprotected 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(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);
switch (experiment.method) {
case Method.kick:

View File

@ -5,17 +5,16 @@ import { getFirstReaction } from "./commands/commandUtils";
describe("Test: throttled users can function with Mjolnir.", function () {
it('throttled users survive being throttled by synapse', async function() {
this.timeout(60000);
let throttledUser = await newTestUser({ name: { contains: "throttled" }, isThrottled: true });
let throttledUserId = await throttledUser.getUserId();
let targetRoom = await throttledUser.createRoom();
// 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;
await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 150, (events) => {
await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 25, (events) => {
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 () {
this.timeout(60000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
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);
// 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(50).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'})));
await Promise.all([...Array(25).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
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 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 {
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/**/*",
"./test/integration/manualLaunchScript.ts",
"./test/integration/roomMembersTest.ts",
"./test/integration/banListTest.ts"
"./test/integration/banListTest.ts",
"./test/integration/reportPollingTest"
]
}