Skip to content

Commit cdece6c

Browse files
authored
Redact on ban: Client implementation (#4867)
* First pass implementation * fix naming/docs * apply lint * Add test for existing behaviour * Add happy path tests * Fix bug identified by tests * ... and this is why we add negative tests too * Add some sanity tests * Apply linter
1 parent d438e25 commit cdece6c

File tree

2 files changed

+287
-50
lines changed

2 files changed

+287
-50
lines changed

spec/unit/models/room.spec.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
Copyright 2025 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
18+
import type { MockedObject } from "jest-mock";
19+
20+
const CREATOR_USER_ID = "@creator:example.org";
21+
const MODERATOR_USER_ID = "@moderator:example.org";
22+
23+
describe("Room", () => {
24+
function createMockClient(): MatrixClient {
25+
return {
26+
supportsThreads: jest.fn().mockReturnValue(true),
27+
decryptEventIfNeeded: jest.fn().mockReturnThis(),
28+
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
29+
} as unknown as MockedObject<MatrixClient>;
30+
}
31+
32+
function createEvent(eventId: string): MatrixEvent {
33+
return new MatrixEvent({
34+
type: "m.room.message",
35+
content: {
36+
body: eventId, // we do this for ease of use, not practicality
37+
},
38+
event_id: eventId,
39+
sender: CREATOR_USER_ID,
40+
});
41+
}
42+
43+
function createRedaction(redactsEventId: string): MatrixEvent {
44+
return new MatrixEvent({
45+
type: "m.room.redaction",
46+
redacts: redactsEventId,
47+
event_id: "$redacts_" + redactsEventId.substring(1),
48+
sender: CREATOR_USER_ID,
49+
});
50+
}
51+
52+
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
53+
return room
54+
.getLiveTimeline()
55+
.getEvents()
56+
.filter((e) => !e.isState());
57+
}
58+
59+
it("should apply redactions locally", async () => {
60+
const mockClient = createMockClient();
61+
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
62+
const messageEvent = createEvent("$message_event");
63+
64+
// Set up the room
65+
await room.addLiveEvents([messageEvent], { addToState: false });
66+
let timeline = getNonStateMainTimelineLiveEvents(room);
67+
expect(timeline.length).toEqual(1);
68+
expect(timeline[0].getId()).toEqual(messageEvent.getId());
69+
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
70+
71+
// Now redact
72+
const redactionEvent = createRedaction(messageEvent.getId()!);
73+
await room.addLiveEvents([redactionEvent], { addToState: false });
74+
timeline = getNonStateMainTimelineLiveEvents(room);
75+
expect(timeline.length).toEqual(2);
76+
expect(timeline[0].getId()).toEqual(messageEvent.getId());
77+
expect(timeline[0].isRedacted()).toEqual(true); // test case
78+
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
79+
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
80+
});
81+
82+
describe("MSC4293: Redact on ban", () => {
83+
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
84+
const mockClient = createMockClient();
85+
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
86+
87+
// Pre-populate room
88+
const messageEvents: MatrixEvent[] = [];
89+
for (let i = 0; i < 3; i++) {
90+
messageEvents.push(createEvent(`$message_${i}`));
91+
}
92+
await room.addLiveEvents(messageEvents, { addToState: false });
93+
94+
if (andGrantPermissions) {
95+
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
96+
return true;
97+
};
98+
}
99+
100+
return { room, messageEvents };
101+
}
102+
103+
function createRedactOnMembershipChange(
104+
targetUserId: string,
105+
senderUserId: string,
106+
membership: string,
107+
): MatrixEvent {
108+
return new MatrixEvent({
109+
type: "m.room.member",
110+
state_key: targetUserId,
111+
content: {
112+
"membership": membership,
113+
"org.matrix.msc4293.redact_events": true,
114+
},
115+
sender: senderUserId,
116+
});
117+
}
118+
119+
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
120+
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
121+
messageEvents.find((e2) => e2.getId() === e.getId()),
122+
);
123+
expect(actualEvents.length).toEqual(messageEvents.length);
124+
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
125+
if (shouldAllBeRedacted) {
126+
expect(redactedEvents.length).toEqual(messageEvents.length);
127+
} else {
128+
expect(redactedEvents.length).toEqual(0);
129+
}
130+
}
131+
132+
it("should apply on ban", async () => {
133+
const { room, messageEvents } = await setupRoom(true);
134+
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
135+
await room.addLiveEvents([banEvent], { addToState: true });
136+
137+
expectRedacted(messageEvents, room, true);
138+
});
139+
140+
it("should apply on kick", async () => {
141+
const { room, messageEvents } = await setupRoom(true);
142+
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
143+
await room.addLiveEvents([kickEvent], { addToState: true });
144+
145+
expectRedacted(messageEvents, room, true);
146+
});
147+
148+
it("should not apply if the user doesn't have permission to redact", async () => {
149+
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
150+
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
151+
await room.addLiveEvents([banEvent], { addToState: true });
152+
153+
expectRedacted(messageEvents, room, false);
154+
});
155+
156+
it("should not apply to self-leaves", async () => {
157+
const { room, messageEvents } = await setupRoom(true);
158+
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
159+
await room.addLiveEvents([leaveEvent], { addToState: true });
160+
161+
expectRedacted(messageEvents, room, false);
162+
});
163+
164+
it("should not apply to invites", async () => {
165+
const { room, messageEvents } = await setupRoom(true);
166+
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
167+
await room.addLiveEvents([leaveEvent], { addToState: true });
168+
169+
expectRedacted(messageEvents, room, false);
170+
});
171+
172+
it("should not apply to joins", async () => {
173+
const { room, messageEvents } = await setupRoom(true);
174+
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
175+
await room.addLiveEvents([leaveEvent], { addToState: true });
176+
177+
expectRedacted(messageEvents, room, false);
178+
});
179+
180+
it("should not apply to knocks", async () => {
181+
const { room, messageEvents } = await setupRoom(true);
182+
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
183+
await room.addLiveEvents([leaveEvent], { addToState: true });
184+
185+
expectRedacted(messageEvents, room, false);
186+
});
187+
});
188+
});

0 commit comments

Comments
 (0)