mirror of
https://github.com/the-draupnir-project/Draupnir.git
synced 2026-04-03 04:45:40 +00:00
* Rename to Draupnir in Appropriate Places * Integrate Code review feedback on CHANGELOG.md
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
// Copyright 2022 - 2024 Gnuxie <Gnuxie@protonmail.com>
|
|
// Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
|
|
//
|
|
// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
|
|
//
|
|
// SPDX-FileAttributionText: <text>
|
|
// This modified file incorporates work from mjolnir
|
|
// https://github.com/matrix-org/mjolnir
|
|
// </text>
|
|
|
|
import { strict as assert } from "assert";
|
|
import { newTestUser } from "./clientHelper";
|
|
import { ABUSE_REPORT_KEY, IReport } from "../../src/report/ReportManager";
|
|
import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils";
|
|
import {
|
|
NoticeMessageContent,
|
|
ReactionContent,
|
|
ReactionEvent,
|
|
RoomMessage,
|
|
Value,
|
|
} from "matrix-protection-suite";
|
|
import { StringEventID } from "@the-draupnir-project/matrix-basic-types";
|
|
|
|
/**
|
|
* Test the ability to turn abuse reports into room messages.
|
|
*/
|
|
|
|
const REPORT_NOTICE_REGEXPS = {
|
|
reporter: /Filed by (?<reporterDisplay>[^ ]*) \((?<reporterId>[^ ]*)\)/,
|
|
accused: /Against (?<accusedDisplay>[^ ]*) \((?<accusedId>[^ ]*)\)/,
|
|
room: /Room (?<roomAliasOrId>[^ ]*)/,
|
|
event: /Event (?<eventId>[^ ]*) Go to event/,
|
|
content: /Content (?<eventContent>.*)/,
|
|
comments: /Comments Comments (?<comments>.*)/,
|
|
};
|
|
|
|
type ReportTemplate = Partial<
|
|
Omit<IReport, "event_id" | "reporter_id" | "accused_id">
|
|
> &
|
|
Pick<IReport, "event_id" | "reporter_id" | "accused_id"> & {
|
|
text?: string;
|
|
comment?: string;
|
|
text_prefix?: string;
|
|
};
|
|
|
|
type UnredactedReaction = Omit<ReactionEvent, "content"> & {
|
|
content: ReactionContent;
|
|
};
|
|
|
|
describe("Test: Reporting abuse", () => {
|
|
// Testing with successive versions of the API.
|
|
//
|
|
// As of this writing, v3 is the standard, while r0 is deprecated. However,
|
|
// both versions are still in use in the wild.
|
|
// Note that this version change only affects the actual URL at which reports
|
|
// are sent.
|
|
for (const endpoint of ["v3", "r0"]) {
|
|
it(
|
|
`Mjölnir intercepts abuse reports with endpoint ${endpoint}`,
|
|
async function (this: DraupnirTestContext) {
|
|
this.timeout(90000);
|
|
if (this.draupnir === undefined) {
|
|
throw new TypeError("setup must have failed.");
|
|
}
|
|
const draupnir = this.draupnir;
|
|
const draupnirSyncClient = draupnirClient();
|
|
if (draupnirSyncClient === null) {
|
|
throw new TypeError("setup must have failed.");
|
|
}
|
|
// Listen for any notices that show up.
|
|
const notices: (Omit<RoomMessage, "content"> & {
|
|
content: NoticeMessageContent;
|
|
})[] = [];
|
|
draupnirSyncClient.on("room.event", (roomId, event) => {
|
|
if (roomId === draupnir.managementRoomID) {
|
|
notices.push(event);
|
|
}
|
|
});
|
|
const reactions: UnredactedReaction[] = [];
|
|
draupnirSyncClient.on("room.event", (roomId, event) => {
|
|
if (roomId === draupnir.managementRoomID) {
|
|
if (Value.Check(ReactionContent, event.content)) {
|
|
reactions.push(event);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Create a few users and a room.
|
|
const goodUser = await newTestUser(this.config.homeserverUrl, {
|
|
name: { contains: "reporting-abuse-good-user" },
|
|
});
|
|
const badUser = await newTestUser(this.config.homeserverUrl, {
|
|
name: { contains: "reporting-abuse-bad-user" },
|
|
});
|
|
const goodUserId = await goodUser.getUserId();
|
|
const badUserId = await badUser.getUserId();
|
|
|
|
const roomId = await goodUser.createRoom({
|
|
invite: [await badUser.getUserId()],
|
|
});
|
|
await goodUser.inviteUser(await badUser.getUserId(), roomId);
|
|
await badUser.joinRoom(roomId);
|
|
|
|
console.log("Test: Reporting abuse - send messages");
|
|
// Exchange a few messages.
|
|
const badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
|
const badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
|
const badText3 = `<b>BAD</b>: ${Math.random()}`; // Will be reported as abuse.
|
|
const badText4 = [...Array(1024)]
|
|
.map((_) => `${Math.random()}`)
|
|
.join(""); // Text is too long.
|
|
const badText5 = [...Array(1024)].map((_) => "ABC").join("\n"); // Text has too many lines.
|
|
const badEventId = await badUser.sendText(roomId, badText);
|
|
const badEventId2 = await badUser.sendText(roomId, badText2);
|
|
const badEventId3 = await badUser.sendText(roomId, badText3);
|
|
const badEventId4 = await badUser.sendText(roomId, badText4);
|
|
const badEventId5 = await badUser.sendText(roomId, badText5);
|
|
const badEvent2Comment = `COMMENT: ${Math.random()}`;
|
|
|
|
console.log("Test: Reporting abuse - send reports");
|
|
const reportsToFind: ReportTemplate[] = [];
|
|
|
|
// Time to report, first without a comment, then with one.
|
|
try {
|
|
await goodUser.doRequest(
|
|
"POST",
|
|
`/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`
|
|
);
|
|
reportsToFind.push({
|
|
reporter_id: goodUserId,
|
|
accused_id: badUserId,
|
|
event_id: badEventId,
|
|
text: badText,
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not send first report", e.body || e);
|
|
throw e;
|
|
}
|
|
|
|
try {
|
|
await goodUser.doRequest(
|
|
"POST",
|
|
`/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`,
|
|
"",
|
|
{
|
|
reason: badEvent2Comment,
|
|
}
|
|
);
|
|
reportsToFind.push({
|
|
reporter_id: goodUserId,
|
|
accused_id: badUserId,
|
|
event_id: badEventId2,
|
|
text: badText2,
|
|
comment: badEvent2Comment,
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not send second report", e.body || e);
|
|
throw e;
|
|
}
|
|
|
|
try {
|
|
await goodUser.doRequest(
|
|
"POST",
|
|
`/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`,
|
|
""
|
|
);
|
|
reportsToFind.push({
|
|
reporter_id: goodUserId,
|
|
accused_id: badUserId,
|
|
event_id: badEventId3,
|
|
text: badText3,
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not send third report", e.body || e);
|
|
throw e;
|
|
}
|
|
|
|
try {
|
|
await goodUser.doRequest(
|
|
"POST",
|
|
`/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`,
|
|
""
|
|
);
|
|
reportsToFind.push({
|
|
reporter_id: goodUserId,
|
|
accused_id: badUserId,
|
|
event_id: badEventId4,
|
|
text_prefix: badText4.substring(0, 256),
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not send fourth report", e.body || e);
|
|
throw e;
|
|
}
|
|
|
|
try {
|
|
await goodUser.doRequest(
|
|
"POST",
|
|
`/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`,
|
|
""
|
|
);
|
|
reportsToFind.push({
|
|
reporter_id: goodUserId,
|
|
accused_id: badUserId,
|
|
event_id: badEventId5,
|
|
text_prefix: badText5.substring(0, 256).split("\n").join(" "),
|
|
});
|
|
} catch (e) {
|
|
console.error("Could not send fifth report", e.body || e);
|
|
throw e;
|
|
}
|
|
|
|
console.log("Test: Reporting abuse - wait");
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
const found: ReportTemplate[] = [];
|
|
for (const toFind of reportsToFind) {
|
|
for (const event of notices) {
|
|
if (Value.Check(RoomMessage, event)) {
|
|
if (
|
|
!(ABUSE_REPORT_KEY in event.content) ||
|
|
typeof event.content[ABUSE_REPORT_KEY] !== "object" ||
|
|
event.content[ABUSE_REPORT_KEY] === null ||
|
|
!("event_id" in event.content[ABUSE_REPORT_KEY]) ||
|
|
typeof event.content[ABUSE_REPORT_KEY].event_id !== "string" ||
|
|
event.content[ABUSE_REPORT_KEY].event_id !== toFind.event_id
|
|
) {
|
|
// Not a report or not our report.
|
|
continue;
|
|
}
|
|
const report = event.content[ABUSE_REPORT_KEY] as IReport;
|
|
const body = event.content.body;
|
|
let matches: Map<string, RegExpMatchArray> | null = new Map();
|
|
for (const key of Object.keys(
|
|
REPORT_NOTICE_REGEXPS
|
|
) as (keyof typeof REPORT_NOTICE_REGEXPS)[]) {
|
|
const match = body.match(REPORT_NOTICE_REGEXPS[key]);
|
|
if (match) {
|
|
console.debug(
|
|
"We have a match",
|
|
key,
|
|
REPORT_NOTICE_REGEXPS[key],
|
|
match.groups
|
|
);
|
|
} else {
|
|
console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]);
|
|
// Not a report, skipping.
|
|
matches = null;
|
|
break;
|
|
}
|
|
matches.set(key, match);
|
|
}
|
|
if (!matches) {
|
|
// Not a report, skipping.
|
|
continue;
|
|
}
|
|
|
|
assert(
|
|
body.length < 3000,
|
|
`The report shouldn't be too long ${body.length}`
|
|
);
|
|
assert(
|
|
body.split("\n").length < 200,
|
|
"The report shouldn't have too many newlines."
|
|
);
|
|
|
|
assert.equal(
|
|
matches.get("event")?.groups?.eventId,
|
|
toFind.event_id,
|
|
"The report should specify the correct event id"
|
|
);
|
|
|
|
assert.equal(
|
|
matches.get("reporter")?.groups?.reporterId,
|
|
toFind.reporter_id,
|
|
"The report should specify the correct reporter"
|
|
);
|
|
assert.equal(
|
|
report.reporter_id,
|
|
toFind.reporter_id,
|
|
"The embedded report should specify the correct reporter"
|
|
);
|
|
assert.ok(
|
|
((reporter: string | undefined) =>
|
|
reporter !== undefined &&
|
|
toFind.reporter_id.includes(reporter))(
|
|
matches.get("reporter")?.groups?.reporterDisplay
|
|
),
|
|
"The report should display the correct reporter"
|
|
);
|
|
|
|
assert.equal(
|
|
matches.get("accused")?.groups?.accusedId,
|
|
toFind.accused_id,
|
|
"The report should specify the correct accused"
|
|
);
|
|
assert.equal(
|
|
report.accused_id,
|
|
toFind.accused_id,
|
|
"The embedded report should specify the correct accused"
|
|
);
|
|
assert.ok(
|
|
((accused: string | undefined) =>
|
|
accused !== undefined && toFind.accused_id.includes(accused))(
|
|
matches.get("accused")?.groups?.accusedDisplay
|
|
),
|
|
"The report should display the correct reporter"
|
|
);
|
|
|
|
if (toFind.text) {
|
|
assert.equal(
|
|
matches.get("content")?.groups?.eventContent,
|
|
toFind.text,
|
|
"The report should contain the text we inserted in the event"
|
|
);
|
|
}
|
|
if (toFind.text_prefix) {
|
|
assert.ok(
|
|
matches
|
|
.get("content")
|
|
?.groups?.eventContent?.startsWith(toFind.text_prefix),
|
|
`The report should contain a prefix of the long text we inserted in the event: ${toFind.text_prefix} in? ${matches.get("content")?.groups?.eventContent}`
|
|
);
|
|
}
|
|
if (toFind.comment) {
|
|
assert.equal(
|
|
matches.get("comments")?.groups?.comments,
|
|
toFind.comment,
|
|
"The report should contain the comment we added"
|
|
);
|
|
}
|
|
assert.equal(
|
|
matches.get("room")?.groups?.roomAliasOrId,
|
|
roomId,
|
|
"The report should specify the correct room"
|
|
);
|
|
assert.equal(
|
|
report.room_id,
|
|
roomId,
|
|
"The embedded report should specify the correct room"
|
|
);
|
|
found.push(toFind);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(found, reportsToFind);
|
|
|
|
// Since Mjölnir is not a member of the room, the only buttons we should find
|
|
// are `help` and `ignore`.
|
|
for (const event of reactions) {
|
|
const regexp = /\/([[^]]*)\]/;
|
|
const matches = event.content["m.relates_to"]?.["key"]?.match(regexp);
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
switch (matches[1]) {
|
|
case "bad-report":
|
|
case "help":
|
|
continue;
|
|
default:
|
|
throw new Error(`Didn't expect label ${matches[1]}`);
|
|
}
|
|
}
|
|
} as unknown as Mocha.AsyncFunc
|
|
);
|
|
}
|
|
it("The redact action works", async function (this: DraupnirTestContext) {
|
|
this.timeout(60000);
|
|
const draupnir = this.draupnir;
|
|
const draupnirSyncClient = draupnirClient();
|
|
if (draupnir === undefined || draupnirSyncClient === null) {
|
|
throw new TypeError("setup code didn't work");
|
|
}
|
|
|
|
// Listen for any notices that show up.
|
|
const notices: (Omit<RoomMessage, "content"> & {
|
|
content: NoticeMessageContent;
|
|
})[] = [];
|
|
draupnirSyncClient.on("room.event", (roomId, event) => {
|
|
if (roomId === draupnir.managementRoomID) {
|
|
if (Value.Check(NoticeMessageContent, event.content)) {
|
|
notices.push(event);
|
|
}
|
|
}
|
|
});
|
|
const reactions: UnredactedReaction[] = [];
|
|
draupnirSyncClient.on("room.event", (roomId, event) => {
|
|
if (roomId === draupnir.managementRoomID) {
|
|
if (Value.Check(ReactionContent, event.content)) {
|
|
reactions.push(event);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Create a moderator.
|
|
const moderatorUser = await newTestUser(this.config.homeserverUrl, {
|
|
name: { contains: "reporting-abuse-moderator-user" },
|
|
});
|
|
await draupnir.client.inviteUser(
|
|
await moderatorUser.getUserId(),
|
|
draupnir.managementRoomID
|
|
);
|
|
await moderatorUser.joinRoom(draupnir.managementRoomID);
|
|
|
|
// Create a few users and a room.
|
|
const goodUser = await newTestUser(this.config.homeserverUrl, {
|
|
name: { contains: "reacting-abuse-good-user" },
|
|
});
|
|
const badUser = await newTestUser(this.config.homeserverUrl, {
|
|
name: { contains: "reacting-abuse-bad-user" },
|
|
});
|
|
|
|
const roomId = await moderatorUser.createRoom({
|
|
invite: [await badUser.getUserId()],
|
|
});
|
|
await moderatorUser.inviteUser(await goodUser.getUserId(), roomId);
|
|
await moderatorUser.inviteUser(await badUser.getUserId(), roomId);
|
|
await badUser.joinRoom(roomId);
|
|
await goodUser.joinRoom(roomId);
|
|
|
|
// Setup Mjölnir as moderator for our room.
|
|
await moderatorUser.inviteUser(await draupnir.client.getUserId(), roomId);
|
|
await moderatorUser.setUserPowerLevel(
|
|
await draupnir.client.getUserId(),
|
|
roomId,
|
|
100
|
|
);
|
|
|
|
console.log("Test: Reporting abuse - send messages");
|
|
// Exchange a few messages.
|
|
const badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
|
const badEventId = await badUser.sendText(roomId, badText);
|
|
|
|
console.log("Test: Reporting abuse - send reports");
|
|
|
|
try {
|
|
await goodUser.doRequest(
|
|
"POST",
|
|
`/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`
|
|
);
|
|
} catch (e) {
|
|
console.error("Could not send first report", e.body || e);
|
|
throw e;
|
|
}
|
|
|
|
console.log("Test: Reporting abuse - wait");
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
const mjolnirRooms = new Set(await draupnir.client.getJoinedRooms());
|
|
assert.ok(
|
|
mjolnirRooms.has(roomId),
|
|
"Draupnir should be a member of the room"
|
|
);
|
|
|
|
// Find the notice
|
|
let noticeId;
|
|
for (const event of notices) {
|
|
if ("content" in event && ABUSE_REPORT_KEY in event.content) {
|
|
if (
|
|
!(ABUSE_REPORT_KEY in event.content) ||
|
|
(event.content[ABUSE_REPORT_KEY] as IReport).event_id !== badEventId
|
|
) {
|
|
// Not a report or not our report.
|
|
continue;
|
|
}
|
|
noticeId = event.event_id;
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(noticeId, "We should have found our notice");
|
|
|
|
// Find the redact button... and click it.
|
|
let redactButtonId: StringEventID | null = null;
|
|
for (const button of reactions) {
|
|
if (
|
|
button.content["m.relates_to"]?.["key"]?.includes("[redact-message]")
|
|
) {
|
|
redactButtonId = button["event_id"];
|
|
await moderatorUser.sendEvent(
|
|
draupnir.managementRoomID,
|
|
"m.reaction",
|
|
button["content"]
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(redactButtonId, "We should have found the redact button");
|
|
|
|
// This should have triggered a confirmation request, with more buttons!
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
// Find the confirmation prompt
|
|
const confirmationPromptEvent = notices.find((event) =>
|
|
event.content.body.includes("🗍 Redact")
|
|
);
|
|
if (confirmationPromptEvent === undefined) {
|
|
throw new TypeError(`We should have found the confirmation prompt`);
|
|
}
|
|
let confirmEventId = null;
|
|
for (const event of reactions) {
|
|
console.debug("Is this the confirm button?", event);
|
|
const content = event.content;
|
|
if (!Value.Check(ReactionContent, content)) {
|
|
console.debug("Not a reaction");
|
|
continue;
|
|
}
|
|
if (!content["m.relates_to"]?.["key"]?.includes("[confirm]")) {
|
|
console.debug("Not confirm");
|
|
continue;
|
|
}
|
|
if (
|
|
content["m.relates_to"]["event_id"] !== confirmationPromptEvent.event_id
|
|
) {
|
|
console.debug("Not reaction to redact button");
|
|
continue;
|
|
}
|
|
|
|
// It's the confirm button, click it!
|
|
confirmEventId = event["event_id"];
|
|
await moderatorUser.sendEvent(
|
|
draupnir.managementRoomID,
|
|
"m.reaction",
|
|
event["content"]
|
|
);
|
|
break;
|
|
}
|
|
assert.ok(confirmEventId, "We should have found the confirm button");
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
// This should have redacted the message.
|
|
const newBadEvent = await draupnir.client.getEvent(roomId, badEventId);
|
|
assert.deepEqual(
|
|
Object.keys(newBadEvent.content),
|
|
[],
|
|
"Redaction should have removed the content of the offending event"
|
|
);
|
|
} as unknown as Mocha.AsyncFunc);
|
|
});
|