diff --git a/spec/test-utils/client.ts b/spec/test-utils/client.ts index 800730fd528..1e8a4643893 100644 --- a/spec/test-utils/client.ts +++ b/spec/test-utils/client.ts @@ -60,6 +60,7 @@ export const getMockClientWithEventEmitter = ( */ export const mockClientMethodsUser = (userId = "@alice:domain") => ({ getUserId: jest.fn().mockReturnValue(userId), + getSafeUserId: jest.fn().mockReturnValue(userId), getUser: jest.fn().mockReturnValue(new User(userId)), isGuest: jest.fn().mockReturnValue(false), mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"), diff --git a/spec/unit/models/poll.spec.ts b/spec/unit/models/poll.spec.ts index feb0c27ffab..c6bc39d53ac 100644 --- a/spec/unit/models/poll.spec.ts +++ b/spec/unit/models/poll.spec.ts @@ -14,26 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IEvent, MatrixEvent, PollEvent } from "../../../src"; +import { IEvent, MatrixEvent, PollEvent, Room } from "../../../src"; import { REFERENCE_RELATION } from "../../../src/@types/extensible_events"; import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE } from "../../../src/@types/polls"; import { PollStartEvent } from "../../../src/extensible_events_v1/PollStartEvent"; import { Poll } from "../../../src/models/poll"; -import { getMockClientWithEventEmitter } from "../../test-utils/client"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client"; jest.useFakeTimers(); describe("Poll", () => { + const userId = "@alice:server.org"; const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), relations: jest.fn(), }); const roomId = "!room:server"; + const room = new Room(roomId, mockClient, userId); + const maySendRedactionForEventSpy = jest.spyOn(room.currentState, "maySendRedactionForEvent"); // 14.03.2022 16:15 const now = 1647270879403; const basePollStartEvent = new MatrixEvent({ ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), room_id: roomId, + sender: userId, }); basePollStartEvent.event.event_id = "$12345"; @@ -42,6 +47,8 @@ describe("Poll", () => { jest.setSystemTime(now); mockClient.relations.mockResolvedValue({ events: [] }); + + maySendRedactionForEventSpy.mockClear().mockReturnValue(true); }); let eventId = 1; @@ -62,7 +69,7 @@ describe("Poll", () => { }; it("initialises with root event", () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); expect(poll.roomId).toEqual(roomId); expect(poll.pollId).toEqual(basePollStartEvent.getId()); expect(poll.pollEvent).toEqual(basePollStartEvent.unstableExtensibleEvent); @@ -73,7 +80,7 @@ describe("Poll", () => { const pollStartEvent = new MatrixEvent( PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), ); - expect(() => new Poll(pollStartEvent, mockClient)).toThrowError("Invalid poll start event."); + expect(() => new Poll(pollStartEvent, mockClient, room)).toThrowError("Invalid poll start event."); }); it("throws when poll start has no event id", () => { @@ -81,12 +88,12 @@ describe("Poll", () => { ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), room_id: roomId, }); - expect(() => new Poll(pollStartEvent, mockClient)).toThrowError("Invalid poll start event."); + expect(() => new Poll(pollStartEvent, mockClient, room)).toThrowError("Invalid poll start event."); }); describe("fetching responses", () => { it("calls relations api and emits", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); const emitSpy = jest.spyOn(poll, "emit"); const responses = await poll.getResponses(); expect(mockClient.relations).toHaveBeenCalledWith(roomId, basePollStartEvent.getId(), "m.reference"); @@ -94,7 +101,7 @@ describe("Poll", () => { }); it("returns existing responses object after initial fetch", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); const responses = await poll.getResponses(); const responses2 = await poll.getResponses(); // only fetched relations once @@ -104,7 +111,7 @@ describe("Poll", () => { }); it("waits for existing relations request to finish when getting responses", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); const firstResponsePromise = poll.getResponses(); const secondResponsePromise = poll.getResponses(); await firstResponsePromise; @@ -121,14 +128,14 @@ describe("Poll", () => { mockClient.relations.mockResolvedValue({ events: [replyEvent, stableResponseEvent, unstableResponseEvent], }); - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); const responses = await poll.getResponses(); expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]); }); describe("with poll end event", () => { - const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable! }); - const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable! }); + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob@server.org" }); + const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable!, sender: "@bob@server.org" }); const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000); const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); @@ -140,10 +147,43 @@ describe("Poll", () => { }); it("sets poll end event with stable event type", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); + jest.spyOn(poll, "emit"); + await poll.getResponses(); + + expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org"); + expect(poll.isEnded).toBe(true); + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + }); + + it("does not set poll end event when sent by a user without redaction rights", async () => { + const poll = new Poll(basePollStartEvent, mockClient, room); + maySendRedactionForEventSpy.mockReturnValue(false); + jest.spyOn(poll, "emit"); + await poll.getResponses(); + + expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org"); + expect(poll.isEnded).toBe(false); + expect(poll.emit).not.toHaveBeenCalledWith(PollEvent.End); + }); + + it("sets poll end event when endevent sender also created the poll, but does not have redaction rights", async () => { + const pollStartEvent = new MatrixEvent({ + ...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(), + room_id: roomId, + sender: "@bob:domain.org", + }); + pollStartEvent.event.event_id = "$6789"; + const poll = new Poll(pollStartEvent, mockClient, room); + const pollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob:domain.org" }); + mockClient.relations.mockResolvedValue({ + events: [pollEndEvent], + }); + maySendRedactionForEventSpy.mockReturnValue(false); jest.spyOn(poll, "emit"); await poll.getResponses(); + expect(maySendRedactionForEventSpy).not.toHaveBeenCalled(); expect(poll.isEnded).toBe(true); expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); }); @@ -152,7 +192,7 @@ describe("Poll", () => { mockClient.relations.mockResolvedValue({ events: [unstablePollEndEvent], }); - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); jest.spyOn(poll, "emit"); await poll.getResponses(); @@ -161,7 +201,7 @@ describe("Poll", () => { }); it("filters out responses that were sent after poll end", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); const responses = await poll.getResponses(); // just response type events @@ -173,7 +213,7 @@ describe("Poll", () => { describe("onNewRelation()", () => { it("discards response if poll responses have not been initialised", () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); jest.spyOn(poll, "emit"); const responseEvent = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); @@ -184,24 +224,107 @@ describe("Poll", () => { }); it("sets poll end event when responses are not initialised", () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); jest.spyOn(poll, "emit"); - const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable! }); + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId }); poll.onNewRelation(stablePollEndEvent); expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); }); + it("does not set poll end event when sent by invalid user", async () => { + maySendRedactionForEventSpy.mockReturnValue(false); + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" }); + const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); + mockClient.relations.mockResolvedValue({ + events: [responseEventAfterEnd], + }); + const poll = new Poll(basePollStartEvent, mockClient, room); + await poll.getResponses(); + jest.spyOn(poll, "emit"); + + poll.onNewRelation(stablePollEndEvent); + + // didn't end, didn't refilter responses + expect(poll.emit).not.toHaveBeenCalled(); + expect(poll.isEnded).toBeFalsy(); + expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org"); + }); + + it("does not set poll end event when an earlier end event already exists", async () => { + const earlierPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now, + ); + const laterPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now + 2000, + ); + + const poll = new Poll(basePollStartEvent, mockClient, room); + await poll.getResponses(); + + poll.onNewRelation(earlierPollEndEvent); + + // first end event set correctly + expect(poll.isEnded).toBeTruthy(); + + // reset spy count + jest.spyOn(poll, "emit").mockClear(); + + poll.onNewRelation(laterPollEndEvent); + // didn't set new end event, didn't refilter responses + expect(poll.emit).not.toHaveBeenCalled(); + expect(poll.isEnded).toBeTruthy(); + }); + + it("replaces poll end event and refilters when an older end event already exists", async () => { + const earlierPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now, + ); + const laterPollEndEvent = makeRelatedEvent( + { type: M_POLL_END.stable!, sender: "@valid:server.org" }, + now + 2000, + ); + const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000); + const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); + const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); + mockClient.relations.mockResolvedValue({ + events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, laterPollEndEvent], + }); + + const poll = new Poll(basePollStartEvent, mockClient, room); + const responses = await poll.getResponses(); + + // all responses have a timestamp < laterPollEndEvent + expect(responses.getRelations().length).toEqual(3); + // first end event set correctly + expect(poll.isEnded).toBeTruthy(); + + // reset spy count + jest.spyOn(poll, "emit").mockClear(); + + // add a valid end event with earlier timestamp + poll.onNewRelation(earlierPollEndEvent); + + // emitted new end event + expect(poll.emit).toHaveBeenCalledWith(PollEvent.End); + // filtered responses and emitted + expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses); + expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]); + }); + it("sets poll end event and refilters responses based on timestamp", async () => { - const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable! }); + const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId }); const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000); const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now); const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000); mockClient.relations.mockResolvedValue({ events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd], }); - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); const responses = await poll.getResponses(); jest.spyOn(poll, "emit"); @@ -216,7 +339,7 @@ describe("Poll", () => { }); it("filters out irrelevant relations", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); // init responses const responses = await poll.getResponses(); jest.spyOn(poll, "emit"); @@ -230,7 +353,7 @@ describe("Poll", () => { }); it("adds poll response relations to responses", async () => { - const poll = new Poll(basePollStartEvent, mockClient); + const poll = new Poll(basePollStartEvent, mockClient, room); // init responses const responses = await poll.getResponses(); jest.spyOn(poll, "emit"); diff --git a/src/models/poll.ts b/src/models/poll.ts index 612b6a0991f..c51c39168f9 100644 --- a/src/models/poll.ts +++ b/src/models/poll.ts @@ -18,6 +18,7 @@ import { M_POLL_END, M_POLL_RESPONSE, PollStartEvent } from "../@types/polls"; import { MatrixClient } from "../client"; import { MatrixEvent } from "./event"; import { Relations } from "./relations"; +import { Room } from "./room"; import { TypedEventEmitter } from "./typed-event-emitter"; export enum PollEvent { @@ -64,13 +65,12 @@ export class Poll extends TypedEventEmitter, P private responses: null | Relations = null; private endEvent: MatrixEvent | undefined; - public constructor(private rootEvent: MatrixEvent, private matrixClient: MatrixClient) { + public constructor(private rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) { super(); if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) { throw new Error("Invalid poll start event."); } this.roomId = this.rootEvent.getRoomId()!; - // @TODO(kerrya) proper way to do this? this.pollEvent = this.rootEvent.unstableExtensibleEvent as unknown as PollStartEvent; } @@ -101,7 +101,7 @@ export class Poll extends TypedEventEmitter, P * @returns void */ public onNewRelation(event: MatrixEvent): void { - if (M_POLL_END.matches(event.getType())) { + if (M_POLL_END.matches(event.getType()) && this.validateEndEvent(event)) { this.endEvent = event; this.refilterResponsesOnEnd(); this.emit(PollEvent.End); @@ -136,7 +136,8 @@ export class Poll extends TypedEventEmitter, P M_POLL_RESPONSE.altName!, ]); - const pollEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType())); + const potentialEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType())); + const pollEndEvent = this.validateEndEvent(potentialEndEvent) ? potentialEndEvent : undefined; const pollCloseTimestamp = pollEndEvent?.getTs() || Number.MAX_SAFE_INTEGER; const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp); @@ -172,4 +173,30 @@ export class Poll extends TypedEventEmitter, P this.emit(PollEvent.Responses, this.responses); } + + private validateEndEvent(endEvent?: MatrixEvent): boolean { + if (!endEvent) { + return false; + } + /** + * Repeated end events are ignored - + * only the first (valid) closure event by origin_server_ts is counted. + */ + if (this.endEvent && this.endEvent.getTs() < endEvent.getTs()) { + return false; + } + + /** + * MSC3381 + * If a m.poll.end event is received from someone other than the poll creator or user with permission to redact + * others' messages in the room, the event must be ignored by clients due to being invalid. + */ + const roomCurrentState = this.room.currentState; + const endEventSender = endEvent.getSender(); + return ( + !!endEventSender && + (endEventSender === this.rootEvent.getSender() || + roomCurrentState.maySendRedactionForEvent(this.rootEvent, endEventSender)) + ); + } } diff --git a/src/models/room.ts b/src/models/room.ts index a82efcc6f85..8d478789b9d 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1897,7 +1897,7 @@ export class Room extends ReadReceipt { const processPollStartEvent = (event: MatrixEvent): void => { if (!M_POLL_START.matches(event.getType())) return; try { - const poll = new Poll(event, this.client); + const poll = new Poll(event, this.client, this); this.polls.set(event.getId()!, poll); this.emit(PollEvent.New, poll); } catch {}