diff --git a/hasura/triggers/player_blocks.sql b/hasura/triggers/player_blocks.sql index 74d51ca2..780faa6b 100644 --- a/hasura/triggers/player_blocks.sql +++ b/hasura/triggers/player_blocks.sql @@ -126,6 +126,18 @@ BEGIN WHERE dc.room_id = LEAST(_a, _b)::text || ':' || GREATEST(_a, _b)::text AND dc.steam_id = _a; + -- Chat hides what the blocked player said from the blocker, so their bell + -- previews go the way NotificationsService.retractChatMessage takes them: + -- blanked as well, since a recipient can restore their own deleted rows, + -- and a push still waiting in its bundling window skips a deleted row. + UPDATE public.notifications n + SET deleted_at = COALESCE(n.deleted_at, now()), + message = '' + WHERE n.steam_id = _a + AND n.type IN ('ChatMessage', 'MatchChatMessage') + AND n.data->>'senderSteamId' = _b::text + AND (n.deleted_at IS NULL OR n.message <> ''); + RETURN NULL; END; $$; diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts index 387da72d..8b155c7a 100644 --- a/src/chat/chat.module.ts +++ b/src/chat/chat.module.ts @@ -13,6 +13,7 @@ import { loggerFactory } from "src/utilities/LoggerFactory"; import { getQueuesProcessors } from "src/utilities/QueueProcessors"; import { ChatController } from "./chat.controller"; import { NotificationsModule } from "src/notifications/notifications.module"; +import { PlayerBlocksModule } from "src/player-blocks/player-blocks.module"; import { ChatQueues } from "./enums/ChatQueues"; import { PruneDirectMessages } from "./jobs/PruneDirectMessages"; import { BackfillDirectMessages } from "./jobs/BackfillDirectMessages"; @@ -24,6 +25,7 @@ import { BackfillDirectMessages } from "./jobs/BackfillDirectMessages"; PostgresModule, forwardRef(() => RconModule), NotificationsModule, + PlayerBlocksModule, BullModule.registerQueue({ name: ChatQueues.ChatMaintenance, }), diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index ab2e8f60..30a28cc9 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -42,6 +42,9 @@ describe("ChatService direct messages", () => { let editAuditIds: string[]; let directReactions: Record | null; let directReactionFailure: Error | undefined; + // What a block committed between a send's access check and its insert does + // to that insert. + let dmInsertBlocked: boolean; // The one direct message the fake database holds, if a test put one there. let directMessage: | { @@ -93,6 +96,10 @@ describe("ChatService direct messages", () => { : []; } + if (sql.includes("INSERT INTO public.direct_messages")) { + return dmInsertBlocked ? [] : [{ id: bindings[0] }]; + } + if (sql.includes("INSERT INTO public.chat_message_edits")) { const id = `edit-audit-${editAuditIds.length + 1}`; editAuditIds.push(id); @@ -161,11 +168,46 @@ describe("ChatService direct messages", () => { ), }); + // [blocker, blocked] + let blocks: Array<[string, string]>; + + const playerBlocks = { + hasBlocked: jest.fn(async (blocker: string, blocked: string) => + blocks.some(([x, y]) => x === blocker && y === blocked), + ), + isBlockedEitherWay: jest.fn(async (a: string, b: string) => + blocks.some( + ([blocker, blocked]) => + (blocker === a && blocked === b) || (blocker === b && blocked === a), + ), + ), + blockedBy: jest.fn( + async (viewer: string) => + new Set( + blocks + .filter(([blocker]) => blocker === viewer) + .map(([, blocked]) => blocked), + ), + ), + blockedAmong: jest.fn(async (viewers: string[], authors: string[]) => { + const found = new Map>(); + + for (const [blocker, blocked] of blocks) { + if (viewers.includes(blocker) && authors.includes(blocked)) { + found.set(blocker, (found.get(blocker) ?? new Set()).add(blocked)); + } + } + + return found; + }), + }; + const notifications = { notifyPlayers: jest.fn(), collapseOlderUnread: jest.fn(), markConversationRead: jest.fn(), retractChatMessage: jest.fn().mockResolvedValue(undefined), + retractChatMessageFromBlocked: jest.fn().mockResolvedValue(undefined), updateChatMessagePreview: jest.fn().mockResolvedValue(undefined), }; @@ -366,6 +408,7 @@ describe("ChatService direct messages", () => { redis.get.mockResolvedValue(null); redis.eval.mockResolvedValue([1, 1]); notifications.retractChatMessage.mockResolvedValue(undefined); + notifications.retractChatMessageFromBlocked.mockResolvedValue(undefined); notifications.updateChatMessagePreview.mockResolvedValue(undefined); directMessage = undefined; acceptedFriendships = [[ME, FRIEND]]; @@ -385,6 +428,8 @@ describe("ChatService direct messages", () => { editAuditIds = []; directReactions = null; directReactionFailure = undefined; + dmInsertBlocked = false; + blocks = []; rcon.send.mockResolvedValue(undefined); rcon.connect.mockResolvedValue(rcon); @@ -395,6 +440,7 @@ describe("ChatService direct messages", () => { postgres as any, { getConnection: () => redis } as any, notifications as any, + playerBlocks as any, ); }); @@ -2927,4 +2973,612 @@ describe("ChatService direct messages", () => { expect(cursorWrites()).toHaveLength(0); }); }); + + describe("blocking", () => { + const MESSAGE_ID = "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f"; + const room = directRoomId(ME, FRIEND); + + let seated: string[]; + let hashes: Record>; + let lines: number; + + const as = (steamId: string) => + ({ + steam_id: steamId, + name: "Someone", + role: "user", + avatar_url: null, + profile_url: null, + }) as any; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + const published = () => + redis.publish.mock.calls.map(([, payload]) => JSON.parse(payload)); + + const recipientsOf = (event: string) => + published() + .filter((sent) => sent.event === event) + .map((sent) => sent.steamId) + .sort(); + + const say = (from: string, message: string, id = `line-${++lines}`) => { + hashes["chat_match_m-1"] ??= {}; + hashes["chat_match_m-1"][id] = JSON.stringify({ + id, + message, + timestamp: new Date(Date.now() - 60_000 + lines * 1_000).toISOString(), + source: "web", + from: { role: "user", name: "Someone", steam_id: from }, + }); + return id; + }; + + beforeEach(() => { + seated = [ME, FRIEND, STRANGER]; + hashes = {}; + lines = 0; + + redis.hgetall.mockImplementation(async (key: string) => { + if (key.startsWith("chat:")) { + return Object.fromEntries( + seated.map((steamId) => [ + steamId, + JSON.stringify({ user: { steam_id: steamId } }), + ]), + ); + } + return { ...hashes[key] }; + }); + redis.hget.mockImplementation(async (key: string, field: string) => { + if (key.startsWith("chat:")) { + return seated.includes(field) + ? JSON.stringify({ user: { steam_id: field } }) + : null; + } + return hashes[key]?.[field] ?? null; + }); + redis.hset.mockImplementation( + async (key: string, field: string, value: string) => { + hashes[key] ??= {}; + hashes[key][field] = value; + return 1; + }, + ); + redis.hdel.mockImplementation(async (key: string, field: string) => { + delete hashes[key]?.[field]; + return 1; + }); + }); + + describe.each([ + ["the player blocked their friend", ME, FRIEND], + ["their friend blocked the player", FRIEND, ME], + ])("a direct conversation once %s", (_, blocker, blocked) => { + beforeEach(() => { + // The block deletes the friendship in the database. It is left in + // place here so the block is the only thing refusing. + blocks = [[blocker, blocked]]; + }); + + it.each([ME, FRIEND])("keeps %s from joining", async (steamId) => { + await service.joinMatchLobby( + client(steamId), + ChatLobbyType.Direct, + room, + ); + + expect(redis.eval).not.toHaveBeenCalled(); + }); + + it.each([ME, FRIEND])("refuses a message from %s", async (steamId) => { + await expect( + service.sendMessageToChat( + ChatLobbyType.Direct, + room, + as(steamId), + "still there?", + ), + ).resolves.toEqual({ + accepted: false, + code: ChatErrorCode.NotAllowed, + }); + + expect( + queries.some(({ sql }) => + sql.includes("INSERT INTO public.direct_messages"), + ), + ).toBe(false); + expect(redis.publish).not.toHaveBeenCalled(); + }); + + it.each([ME, FRIEND])( + "refuses %s editing their own message", + async (steamId) => { + directMessage = { + id: MESSAGE_ID, + roomId: room, + author: steamId, + message: "typo", + open: true, + editedAt: null, + }; + + await expect( + service.editMessage( + ChatLobbyType.Direct, + room, + MESSAGE_ID, + as(steamId), + "fixed", + ), + ).resolves.toEqual({ + edited: false, + code: ChatErrorCode.NotAllowed, + }); + + expect(directMessage.message).toBe("typo"); + expect(redis.publish).not.toHaveBeenCalled(); + }, + ); + + it.each([ME, FRIEND])("refuses %s reacting", async (steamId) => { + directMessage = { + id: MESSAGE_ID, + roomId: room, + author: steamId === ME ? FRIEND : ME, + message: "gg", + open: true, + editedAt: null, + }; + redis.eval.mockResolvedValue(1); + + await expect( + service.toggleReaction( + ChatLobbyType.Direct, + room, + MESSAGE_ID, + "heart", + as(steamId), + ), + ).resolves.toEqual({ toggled: false, code: ChatErrorCode.NotAllowed }); + + expect( + queries.some(({ sql }) => + sql.includes("INSERT INTO public.direct_message_reactions"), + ), + ).toBe(false); + expect(redis.publish).not.toHaveBeenCalled(); + }); + + it.each([ME, FRIEND])( + "keeps %s from marking it read", + async (steamId) => { + await expect( + service.markThreadRead(ChatLobbyType.Direct, room, as(steamId)), + ).resolves.toBeNull(); + + expect( + queries.some(({ sql }) => sql.includes("chat_read_state")), + ).toBe(false); + }, + ); + + it.each([ME, FRIEND])( + "refuses %s deleting their own message", + async (steamId) => { + directMessage = { + id: MESSAGE_ID, + roomId: room, + author: steamId, + message: "typo", + open: true, + editedAt: null, + }; + + await expect( + service.deleteMessage( + ChatLobbyType.Direct, + room, + MESSAGE_ID, + as(steamId), + ), + ).resolves.toEqual({ + deleted: false, + code: ChatErrorCode.NotAllowed, + }); + + expect(directMessage).toBeDefined(); + expect(redis.publish).not.toHaveBeenCalled(); + }, + ); + }); + + it("refuses the pair's conversation under any id but the canonical one", async () => { + const [low, high] = room.split(":"); + + for (const id of [`${high}:${low}`, `${low}:0${high}`]) { + await service.joinMatchLobby(client(ME), ChatLobbyType.Direct, id); + + await expect( + service.sendMessageToChat(ChatLobbyType.Direct, id, as(ME), "hi"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.NotAllowed }); + } + + expect(redis.eval).not.toHaveBeenCalled(); + expect(redis.publish).not.toHaveBeenCalled(); + }); + + it("stores, delivers and announces nothing when a block lands between the check and the insert", async () => { + dmInsertBlocked = true; + + await expect( + service.sendMessageToChat(ChatLobbyType.Direct, room, as(ME), "hi"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.NotAllowed }); + await flush(); + + const [insert] = queries.filter(({ sql }) => + sql.includes("INSERT INTO public.direct_messages"), + ); + + expect(insert.sql).toContain("WHERE NOT public.is_blocked_either_way("); + expect( + queries.some(({ sql }) => + sql.includes("INSERT INTO public.direct_conversations"), + ), + ).toBe(false); + expect(redis.publish).not.toHaveBeenCalled(); + expect(notifications.notifyPlayers).not.toHaveBeenCalled(); + }); + + it("keeps a direct message from a player its recipient has just blocked", async () => { + blocks = [[FRIEND, ME]]; + playerBlocks.isBlockedEitherWay.mockResolvedValueOnce(false); + + await expect( + service.sendMessageToChat(ChatLobbyType.Direct, room, as(ME), "hi"), + ).resolves.toMatchObject({ accepted: true }); + await flush(); + await flush(); + + expect(published().map(({ steamId }) => steamId)).not.toContain(FRIEND); + expect(playerBlocks.hasBlocked).toHaveBeenCalledWith(FRIEND, ME); + expect(notifications.notifyPlayers).not.toHaveBeenCalled(); + }); + + it("keeps the socket's cleanup when the history's block lookup fails", async () => { + const socket = client(ME); + playerBlocks.blockedBy.mockRejectedValueOnce(new Error("pool timeout")); + + await expect( + service.joinMatchLobby(socket, ChatLobbyType.Match, "m-1"), + ).rejects.toThrow("pool timeout"); + + expect(redis.eval).toHaveBeenCalled(); + expect(socket.on).toHaveBeenCalledWith("close", expect.any(Function)); + }); + + it("lets the same pair back into their conversation once unblocked", async () => { + await service.joinMatchLobby(client(ME), ChatLobbyType.Direct, room); + + expect(redis.eval).toHaveBeenCalled(); + expect(playerBlocks.isBlockedEitherWay).toHaveBeenCalledWith(ME, FRIEND); + }); + + describe("history", () => { + const historyOf = async (steamId: string) => { + const socket = client(steamId); + + await service.joinMatchLobby(socket, ChatLobbyType.Match, "m-1"); + + const sent = socket.send.mock.calls + .map(([payload]: [string]) => JSON.parse(payload)) + .find(({ event }: any) => event === "lobby:match:m-1:messages"); + + return sent.data.messages.map(({ message }: any) => message); + }; + + it("leaves out what the viewer blocked, for the viewer only", async () => { + say(FRIEND, "from friend"); + say(STRANGER, "from stranger"); + say(ME, "from me"); + blocks = [[ME, FRIEND]]; + + expect(await historyOf(ME)).toEqual(["from stranger", "from me"]); + expect(await historyOf(FRIEND)).toEqual([ + "from friend", + "from stranger", + "from me", + ]); + expect(await historyOf(STRANGER)).toEqual([ + "from friend", + "from stranger", + "from me", + ]); + expect(playerBlocks.blockedBy).toHaveBeenCalledWith(ME); + }); + + it("hides nothing from the player a block is aimed at", async () => { + say(FRIEND, "from friend"); + blocks = [[FRIEND, ME]]; + + expect(await historyOf(ME)).toEqual(["from friend"]); + }); + + it("re-sends a moved draft's history to each player without what they blocked", async () => { + say(FRIEND, "from friend"); + say(STRANGER, "from stranger"); + blocks = [[ME, FRIEND]]; + redis.eval.mockResolvedValueOnce(2); + + await service.migrateLobbyMessages( + ChatLobbyType.Draft, + "d-1", + ChatLobbyType.Match, + "m-1", + ); + await flush(); + + const resent = Object.fromEntries( + published() + .filter(({ event }) => event === "lobby:match:m-1:messages") + .map(({ steamId, data }) => [ + steamId, + data.messages.map(({ message }: any) => message), + ]), + ); + + expect(resent).toEqual({ + [ME]: ["from stranger"], + [FRIEND]: ["from friend", "from stranger"], + [STRANGER]: ["from friend", "from stranger"], + }); + expect(playerBlocks.blockedAmong).toHaveBeenCalledTimes(1); + expect(playerBlocks.blockedAmong).toHaveBeenCalledWith( + [ME, FRIEND, STRANGER], + [FRIEND, STRANGER], + ); + }); + + it("logs a re-send whose block lookup fails instead of leaving it unhandled", async () => { + say(FRIEND, "from friend"); + redis.eval.mockResolvedValueOnce(1); + playerBlocks.blockedAmong.mockRejectedValueOnce( + new Error("pool timeout"), + ); + + await service.migrateLobbyMessages( + ChatLobbyType.Draft, + "d-1", + ChatLobbyType.Match, + "m-1", + ); + await flush(); + + expect(logger.warn).toHaveBeenCalledWith( + "unable to re-send history to match:m-1", + expect.any(Error), + ); + }); + }); + + describe("live", () => { + beforeEach(() => { + redis.eval.mockImplementation(async (script: string) => { + if (script.includes("INCR")) { + return 1; + } + if (script.includes("cjson")) { + return JSON.stringify({ heart: [STRANGER] }); + } + return 1; + }); + }); + + it("never sends a line or its edit to a player who blocked its author", async () => { + blocks = [[ME, FRIEND]]; + + const sent = await service.sendMessageToChat( + ChatLobbyType.Match, + "m-1", + as(FRIEND), + "hello", + ); + await flush(); + + expect(recipientsOf("lobby:match:m-1:chat")).toEqual( + [FRIEND, STRANGER].sort(), + ); + expect(playerBlocks.blockedAmong).toHaveBeenCalledWith( + [ME, FRIEND, STRANGER], + [FRIEND], + ); + + await expect( + service.editMessage( + ChatLobbyType.Match, + "m-1", + sent.accepted ? sent.messageId : "", + as(FRIEND), + "hello again", + ), + ).resolves.toMatchObject({ edited: true }); + await flush(); + + expect(recipientsOf("lobby:match:m-1:edited")).toEqual( + [FRIEND, STRANGER].sort(), + ); + }); + + it("logs a line or edit whose broadcast fails instead of leaving it unhandled", async () => { + jest.spyOn(service, "to").mockRejectedValue(new Error("pool timeout")); + + const sent = await service.sendMessageToChat( + ChatLobbyType.Match, + "m-1", + as(FRIEND), + "hello", + ); + await flush(); + + await expect( + service.editMessage( + ChatLobbyType.Match, + "m-1", + sent.accepted ? sent.messageId : "", + as(FRIEND), + "hello again", + ), + ).resolves.toMatchObject({ edited: true }); + await flush(); + + expect(sent).toMatchObject({ accepted: true }); + expect(logger.warn).toHaveBeenCalledWith( + "unable to broadcast a message to match:m-1", + expect.any(Error), + ); + expect(logger.warn).toHaveBeenCalledWith( + "unable to broadcast an edit to match:m-1", + expect.any(Error), + ); + }); + + it("still sends the blocker's own lines to the player they blocked", async () => { + blocks = [[ME, FRIEND]]; + + await service.sendMessageToChat( + ChatLobbyType.Match, + "m-1", + as(ME), + "hello", + ); + await flush(); + + expect(recipientsOf("lobby:match:m-1:chat")).toEqual( + [ME, FRIEND, STRANGER].sort(), + ); + }); + + it("sends a hidden line's delete and reactions to everyone, with nothing in them to read", async () => { + blocks = [[ME, FRIEND]]; + const id = say(FRIEND, "hidden", MESSAGE_ID); + + await service.toggleReaction( + ChatLobbyType.Match, + "m-1", + id, + "heart", + as(STRANGER), + ); + + await service.deleteMessage(ChatLobbyType.Match, "m-1", id, as(FRIEND)); + await flush(); + + for (const event of ["reaction", "deleted"]) { + const sent = published().filter( + (published) => published.event === `lobby:match:m-1:${event}`, + ); + + expect(sent.map(({ steamId }) => steamId).sort()).toEqual( + [ME, FRIEND, STRANGER].sort(), + ); + + for (const { data } of sent) { + expect(JSON.stringify(data)).not.toContain("hidden"); + expect(Object.keys(data).sort()).toEqual( + event === "reaction" ? ["id", "reactions"] : ["id"], + ); + } + } + }); + }); + + describe("notifications", () => { + const sayInTournament = async (from: string) => { + tournament.roster = [ME, FRIEND, STRANGER]; + + const result = await service.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + as(from), + "hello", + ); + + await flush(); + await flush(); + + return result.accepted ? result.messageId : undefined; + }; + + it("writes none for a player who blocked the sender", async () => { + blocks = [[ME, FRIEND]]; + + await sayInTournament(FRIEND); + + expect(notifications.notifyPlayers).toHaveBeenCalledWith( + "ChatMessage", + expect.objectContaining({ steamIds: [STRANGER] }), + ); + expect(notifications.collapseOlderUnread).toHaveBeenCalledWith( + "ChatMessage", + "tournament:t-1", + [STRANGER], + ); + }); + + it("writes none at all when everyone else blocked the sender", async () => { + blocks = [ + [ME, FRIEND], + [STRANGER, FRIEND], + ]; + + await sayInTournament(FRIEND); + + expect(playerBlocks.blockedAmong).toHaveBeenCalledWith( + [STRANGER, ME], + [FRIEND], + ); + expect(notifications.notifyPlayers).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("catches up on a block that landed while the rows were being written", async () => { + const messageId = await sayInTournament(FRIEND); + + expect( + notifications.retractChatMessageFromBlocked, + ).toHaveBeenCalledWith(messageId); + expect( + notifications.retractChatMessageFromBlocked.mock + .invocationCallOrder[0], + ).toBeGreaterThan( + notifications.notifyPlayers.mock.invocationCallOrder[0], + ); + }); + + it("still notifies the player a block is aimed at", async () => { + blocks = [[FRIEND, ME]]; + + await sayInTournament(FRIEND); + + expect( + notifications.notifyPlayers.mock.calls[0][1].steamIds.sort(), + ).toEqual([ME, STRANGER].sort()); + }); + }); + + it("asks the rail for the caller's own blocks only", async () => { + await service.getDirectConversations(as(ME)); + + const [rail] = queries.filter(({ sql }) => + sql.includes("FROM public.direct_conversations dc"), + ); + + expect(rail.bindings).toEqual([ME]); + expect(rail.sql).toMatch( + /NOT EXISTS \([\s\S]*public\.player_blocks pb[\s\S]*pb\.blocker_steam_id = dc\.steam_id/, + ); + expect(rail.sql).not.toContain("pb.blocked_steam_id = dc.steam_id"); + }); + }); }); diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index ca22231f..2eeda4f1 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -15,9 +15,10 @@ import { import { isRoleAbove, rolesAtOrAbove } from "src/utilities/isRoleAbove"; import { NotificationsService } from "src/notifications/notifications.service"; import { PostgresService } from "src/postgres/postgres.service"; +import { PlayerBlocksService } from "src/player-blocks/player-blocks.service"; import { chatThreadKey } from "src/notifications/push/notification-delivery"; import { SystemSettingName } from "src/system/enums/SystemSettingName"; -import { parseDirectRoomId } from "./utilities/directRoomId"; +import { directRoomId, parseDirectRoomId } from "./utilities/directRoomId"; import { ChatErrorCode } from "./enums/ChatErrorCode"; import { ChatMessage, ChatMessageSource } from "./types/ChatMessage"; import { ChatSendResult } from "./types/ChatSendResult"; @@ -273,6 +274,7 @@ export class ChatService { private readonly postgres: PostgresService, private readonly redisManager: RedisManagerService, private readonly notifications: NotificationsService, + private readonly playerBlocks: PlayerBlocksService, ) { this.redis = this.redisManager.getConnection(); } @@ -308,6 +310,10 @@ export class ChatService { client.id, ); + client.on("close", () => { + void this.removeFromLobby(type, id, client); + }); + if (added === 1 && count === 1) { void this.to(type, id, "joined", { user: { @@ -336,14 +342,10 @@ export class ChatService { event: `lobby:${type}:${id}:messages`, data: { id, - messages: await this.getMessages(type, id), + messages: await this.historyFor(type, id, String(user.steam_id)), }, }), ); - - client.on("close", () => { - void this.removeFromLobby(type, id, client); - }); } // Who is allowed in a room at all. @@ -539,6 +541,13 @@ export class ChatService { return false; } + // Anything else names the same pair under a room id that nothing + // keyed on the canonical one -- the block trigger, the rail's filter + // -- would ever match. + if (id !== directRoomId(parties[0], parties[1])) { + return false; + } + // Being one of the two parties is not on its own an authorization: // anyone can build the id for any pair of steam ids, since it is just // their sorted pair. The friendship is the only thing standing between @@ -576,6 +585,15 @@ export class ChatService { return false; } + if ( + await this.playerBlocks.isBlockedEitherWay( + String(user.steam_id), + otherSteamId, + ) + ) { + return false; + } + break; } default: @@ -586,6 +604,44 @@ export class ChatService { return true; } + // Hiding is one-directional: what the viewer blocked is left out, what + // blocked the viewer is not, so nothing here tells anyone they were blocked. + private async historyFor( + type: ChatLobbyType, + id: string, + viewer: string, + ): Promise { + const [messages, blocked] = await Promise.all([ + this.getMessages(type, id), + this.playerBlocks.blockedBy(viewer), + ]); + + return ChatService.withoutAuthors(messages, blocked); + } + + private static withoutAuthors( + messages: ChatMessage[], + blocked: Set | undefined, + ): ChatMessage[] { + if (!blocked || blocked.size === 0) { + return messages; + } + + return messages.filter( + (message) => !blocked.has(ChatService.authorSteamId(message)), + ); + } + + private static authorsOf(messages: ChatMessage[]): string[] { + return [ + ...new Set( + messages + .map((message) => ChatService.authorSteamId(message)) + .filter((steamId): steamId is string => steamId !== null), + ), + ]; + } + // A room's history, from whichever store holds it. DMs are durable and live // in postgres; every other room is redis behind its own TTL. private async getMessages(type: ChatLobbyType, id: string) { @@ -817,7 +873,9 @@ export class ChatService { }; if (type === ChatLobbyType.Direct) { - await this.storeDirectMessage(id, message); + if (!(await this.storeDirectMessage(id, message))) { + return { accepted: false, code: ChatErrorCode.NotAllowed }; + } } else { const messageKey = `chat_${type}_${id}`; // Keyed by id and not `${steam_id}:${now}`, which silently dropped a @@ -838,7 +896,14 @@ export class ChatService { const outgoing: ChatMessage = { ...message, reactions: {} }; - void this.to(type, id, "chat", outgoing); + void this.to(type, id, "chat", outgoing, message.from.steam_id).catch( + (error) => { + this.logger.warn( + `unable to broadcast a message to ${type}:${id}`, + error, + ); + }, + ); if (type === ChatLobbyType.Direct) { void this.deliverDirectMessage(id, player, outgoing); @@ -1263,7 +1328,14 @@ export class ChatService { ); if (swapped === 1) { - return await this.announceEdit(type, id, messageId, text, editedAt); + return await this.announceEdit( + type, + id, + messageId, + String(user.steam_id), + text, + editedAt, + ); } await this.discardEdit(auditId); @@ -1315,6 +1387,7 @@ export class ChatService { ChatLobbyType.Direct, roomId, messageId, + String(user.steam_id), row.message, new Date(row.edited_at).toISOString(), ); @@ -1419,13 +1492,22 @@ export class ChatService { type: ChatLobbyType, id: string, messageId: string, + author: string, text: string, editedAt: string, ): Promise { - void this.to(type, id, "edited", { - id: messageId, - message: text, - edited_at: editedAt, + void this.to( + type, + id, + "edited", + { + id: messageId, + message: text, + edited_at: editedAt, + }, + author, + ).catch((error) => { + this.logger.warn(`unable to broadcast an edit to ${type}:${id}`, error); }); await this.notifications @@ -1587,7 +1669,11 @@ export class ChatService { const members = await this.getLobbyMemberSteamIds(type, id); const senderSteamId = String(sender.steam_id); - const targets = members.filter((steamId) => steamId !== senderSteamId); + const others = members.filter((steamId) => steamId !== senderSteamId); + const hiding = await this.playerBlocks.blockedAmong(others, [ + senderSteamId, + ]); + const targets = others.filter((steamId) => !hiding.has(steamId)); if (targets.length === 0) { return; @@ -1636,6 +1722,8 @@ export class ChatService { id: string, messageId: string, ) { + await this.notifications.retractChatMessageFromBlocked(messageId); + if (type === ChatLobbyType.Direct) { const [row] = await this.postgres.query< Array<{ message: string; edited_at: Date | null }> @@ -2041,6 +2129,12 @@ export class ChatService { continue; } + if ( + await this.playerBlocks.hasBlocked(steamId, String(sender.steam_id)) + ) { + continue; + } + await this.redis.publish( "send-message-to-steam-id", JSON.stringify({ @@ -2070,22 +2164,33 @@ export class ChatService { // milliseconds ahead leaves a just-read message looking unread -- forever, // and pushing every time. The websocket broadcast keeps the pod's timestamp; // clients dedupe on the message id, not on when it claims to have happened. + // + // A block committed after the send's access check still stops the insert, + // and with it the rail, the delivery and the notification. private async storeDirectMessage( roomId: string, message: { id: string; message: string; from: User }, - ) { + ): Promise { const parties = parseDirectRoomId(roomId); if (!parties) { - return; + return false; } - await this.postgres.query( + const stored = await this.postgres.query>( `INSERT INTO public.direct_messages (id, room_id, from_steam_id, message) - VALUES ($1::uuid, $2, $3::bigint, $4)`, + SELECT $1::uuid, $2, $3::bigint, $4 + WHERE NOT public.is_blocked_either_way( + split_part($2, ':', 1)::bigint, + split_part($2, ':', 2)::bigint) + RETURNING id::text AS id`, [message.id, roomId, message.from.steam_id, message.message], ); + if (stored.length === 0) { + return false; + } + // A message puts the conversation back on the bar, even if it was removed // from it -- someone writing to you is exactly when you want to see them // again. It only jumps to the top when it was off the bar; a conversation @@ -2110,6 +2215,8 @@ export class ChatService { ); await this.enforceDirectBarLimit(parties); + + return true; } // How many conversations the rail holds. Past this the quietest one drops @@ -2341,6 +2448,17 @@ export class ChatService { AND other.steam_id <> dc.steam_id LEFT JOIN public.players peer ON peer.steam_id = other.steam_id WHERE dc.steam_id = $1::bigint + -- Only the blocker's rail: the other side's stays as it was, so it + -- does not tell them. The room id is directRoomId()'s. + AND NOT EXISTS ( + SELECT 1 + FROM public.player_blocks pb + WHERE pb.blocker_steam_id = dc.steam_id + AND dc.room_id = + LEAST(pb.blocker_steam_id, pb.blocked_steam_id)::text + || ':' || + GREATEST(pb.blocker_steam_id, pb.blocked_steam_id)::text + ) -- The rail's own order. last_message_at only breaks ties between rows -- that have never been arranged relative to each other. ORDER BY dc.position ASC, dc.last_message_at DESC @@ -2395,35 +2513,78 @@ export class ChatService { })); } + // What someone said never reaches a player who blocked them. History is + // per recipient, so it has no way out through here: see resendHistory. + public to( + type: ChatLobbyType, + id: string, + event: "chat" | "edited", + data: Record, + author: string, + ): Promise; + public to( + type: ChatLobbyType, + id: string, + event: "deleted" | "reaction" | "list" | "joined" | "left", + data: Record, + ): Promise; public async to( type: ChatLobbyType, id: string, - event: - | "chat" - | "edited" - | "deleted" - | "reaction" - | "list" - | "messages" - | "joined" - | "left", + event: string, data: Record, - ) { + author?: string, + ): Promise { const users = await this.getAllUsersInLobby(type, id); const eventName = `lobby:${type}:${id}:${event}`; + const hiding = + author === undefined + ? new Map>() + : await this.playerBlocks.blockedAmong( + users.map(({ steamId }) => steamId), + [author], + ); + for (const { steamId } of users) { - await this.redis.publish( - "send-message-to-steam-id", - JSON.stringify({ - steamId, - event: eventName, - data, - }), - ); + if (hiding.has(steamId)) { + continue; + } + + await this.publishTo(steamId, eventName, data); } } + private async resendHistory( + type: ChatLobbyType, + id: string, + messages: ChatMessage[], + ) { + const users = await this.getAllUsersInLobby(type, id); + const blocked = await this.playerBlocks.blockedAmong( + users.map(({ steamId }) => steamId), + ChatService.authorsOf(messages), + ); + + for (const { steamId } of users) { + await this.publishTo(steamId, `lobby:${type}:${id}:messages`, { + id, + messages: ChatService.withoutAuthors(messages, blocked.get(steamId)), + }); + } + } + + private async publishTo( + steamId: string, + event: string, + data: Record, + ) { + await this.redis.publish( + "send-message-to-steam-id", + JSON.stringify({ steamId, event, data }), + ); + } + public async removeFromLobby( type: ChatLobbyType, id: string, @@ -2749,6 +2910,8 @@ export class ChatService { const messages = await this.getRoomMessages(toType, toId); - void this.to(toType, toId, "messages", { id: toId, messages }); + void this.resendHistory(toType, toId, messages).catch((error) => { + this.logger.warn(`unable to re-send history to ${toType}:${toId}`, error); + }); } } diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index d9b62486..7a192760 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -789,6 +789,27 @@ export class NotificationsService { ); } + // For a recipient who blocked the sender after the rows were aimed but + // before they were written, which the block's own cleanup ran too early to + // see. Blanked the way retractChatMessage blanks, for the same reasons. + async retractChatMessageFromBlocked(messageId: string) { + await this.postgres.query( + `UPDATE public.notifications n + SET deleted_at = COALESCE(n.deleted_at, now()), + message = '' + WHERE n.data->>'messageId' = $1 + AND n.type IN ('ChatMessage', 'MatchChatMessage') + AND (n.deleted_at IS NULL OR n.message <> '') + AND EXISTS ( + SELECT 1 + FROM public.player_blocks pb + WHERE pb.blocker_steam_id = n.steam_id + AND pb.blocked_steam_id::text = n.data->>'senderSteamId' + )`, + [messageId], + ); + } + // Read and collapsed rows too: the bell keeps read rows on show, and a // recipient can restore a collapsed one, so either would otherwise keep the // text the author took back. A preview is never empty, which is what tells a diff --git a/src/player-blocks/player-blocks.service.spec.ts b/src/player-blocks/player-blocks.service.spec.ts index 5a59f99e..e0c5c72e 100644 --- a/src/player-blocks/player-blocks.service.spec.ts +++ b/src/player-blocks/player-blocks.service.spec.ts @@ -63,6 +63,40 @@ describe("PlayerBlocksService", () => { }); }); + describe("blockedAmong", () => { + it("never queries without viewers or authors", async () => { + await expect(service.blockedAmong([], ["2"])).resolves.toEqual(new Map()); + await expect(service.blockedAmong(["1"], [])).resolves.toEqual(new Map()); + expect(postgres.query).not.toHaveBeenCalled(); + }); + + it("maps each viewer to the authors that viewer blocked, in one query", async () => { + postgres.query.mockResolvedValue([ + { viewer: "1", author: "10" }, + { viewer: "1", author: "11" }, + { viewer: "2", author: "10" }, + ]); + + await expect( + service.blockedAmong(["1", "2", "3"], ["10", "11"]), + ).resolves.toEqual( + new Map([ + ["1", new Set(["10", "11"])], + ["2", new Set(["10"])], + ]), + ); + + expect(postgres.query).toHaveBeenCalledTimes(1); + const [sql, params] = postgres.query.mock.calls[0]; + expect(sql).toContain("blocker_steam_id = ANY($1::bigint[])"); + expect(sql).toContain("blocked_steam_id = ANY($2::bigint[])"); + expect(params).toEqual([ + ["1", "2", "3"], + ["10", "11"], + ]); + }); + }); + describe("filterUnblocked", () => { it("never queries for an empty candidate list", async () => { await expect(service.filterUnblocked("1", [])).resolves.toEqual([]); diff --git a/src/player-blocks/player-blocks.service.ts b/src/player-blocks/player-blocks.service.ts index 540c0820..a94e749f 100644 --- a/src/player-blocks/player-blocks.service.ts +++ b/src/player-blocks/player-blocks.service.ts @@ -36,6 +36,40 @@ export class PlayerBlocksService { return new Set(rows.map((row) => row.steam_id)); } + // Server-side filtering only: it says who blocked whom, so never hand it to + // a client. + public async blockedAmong( + viewers: Array, + authors: Array, + ): Promise>> { + const blocked = new Map>(); + + if (viewers.length === 0 || authors.length === 0) { + return blocked; + } + + const rows = await this.postgres.query< + Array<{ viewer: string; author: string }> + >( + `SELECT blocker_steam_id::text AS viewer, + blocked_steam_id::text AS author + FROM public.player_blocks + WHERE blocker_steam_id = ANY($1::bigint[]) + AND blocked_steam_id = ANY($2::bigint[])`, + [viewers, authors], + ); + + for (const { viewer, author } of rows) { + if (!blocked.has(viewer)) { + blocked.set(viewer, new Set()); + } + + blocked.get(viewer).add(author); + } + + return blocked; + } + public async filterUnblocked( actor: string, candidates: Array, diff --git a/test/chat-blocks.spec.ts b/test/chat-blocks.spec.ts new file mode 100644 index 00000000..7b496bd7 --- /dev/null +++ b/test/chat-blocks.spec.ts @@ -0,0 +1,696 @@ +import { randomUUID } from "crypto"; +import IORedis, { Redis } from "ioredis"; +import { GenericContainer, StartedTestContainer } from "testcontainers"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; +import { ChatService } from "./../src/chat/chat.service"; +import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; +import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; +import { directRoomId } from "./../src/chat/utilities/directRoomId"; +import { PlayerBlocksService } from "./../src/player-blocks/player-blocks.service"; +import { NotificationsService } from "./../src/notifications/notifications.service"; +import { NotificationPreferencesService } from "./../src/notifications/preferences/notification-preferences.service"; + +// A block hides what the blocked player says from the blocker in group rooms, +// and closes a DM in both directions. Against real Postgres (the block, its +// trigger, the bell) and real redis (rooms, history, fan-out). +describe("chat blocks (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let container: StartedTestContainer; + let redis: Redis; + let chat: ChatService; + let blocks: PlayerBlocksService; + + let roster: string[]; + let friendshipOverride: boolean; + let beforeBellInsert: (() => Promise) | undefined; + + let to: jest.SpyInstance; + let notify: jest.SpyInstance; + let resend: jest.SpyInstance; + let deliver: jest.SpyInstance; + let publish: jest.SpyInstance; + + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + const hasura = { + query: jest.fn(async (query: any) => { + if (query.players_by_pk) { + const [player] = await postgres.query< + Array<{ steam_id: string; name: string; role: string }> + >( + `SELECT steam_id::text AS steam_id, name, role::text AS role + FROM players WHERE steam_id = $1::bigint`, + [query.players_by_pk.__args.steam_id], + ); + return { players_by_pk: player ?? null }; + } + + // Answers the access check, the notification roster and the thread + // label at once: everyone on `roster` is in the match. + if (query.matches_by_pk) { + return { + matches_by_pk: { + is_coach: false, + is_organizer: false, + is_in_lineup: true, + organizer_steam_id: null, + lineup_1: { + name: "Blue", + coach_steam_id: null, + lineup_players: roster.map((steam_id) => ({ steam_id })), + }, + lineup_2: { name: "Red", coach_steam_id: null, lineup_players: [] }, + }, + }; + } + + if (query.friends) { + if (friendshipOverride) { + return { friends: [{ status: "Accepted" }] }; + } + + const [first] = query.friends.__args.where._or; + const friends = await postgres.query>( + `SELECT status FROM friends + WHERE status = 'Accepted' + AND ((player_steam_id = $1::bigint + AND other_player_steam_id = $2::bigint) + OR (player_steam_id = $2::bigint + AND other_player_steam_id = $1::bigint))`, + [ + String(first.player_steam_id._eq), + String(first.other_player_steam_id._eq), + ], + ); + return { friends }; + } + + return {}; + }), + mutation: jest.fn(async (mutation: any) => { + const insert = mutation?.insert_notifications; + + if (!insert) { + return {}; + } + + const hook = beforeBellInsert; + beforeBellInsert = undefined; + await hook?.(); + + const returning: Array<{ id: string }> = []; + + for (const object of insert.__args.objects) { + const [row] = await postgres.query>( + `INSERT INTO notifications + (type, title, message, role, steam_id, entity_id, in_app, data) + VALUES ($1, $2, $3, $4, $5::bigint, $6, $7, $8::jsonb) + RETURNING id::text AS id`, + [ + object.type, + object.title, + object.message, + object.role, + object.steam_id, + object.entity_id ?? null, + object.in_app ?? true, + object.data ? JSON.stringify(object.data) : null, + ], + ); + returning.push(row); + } + + return { insert_notifications: { returning } }; + }), + }; + + beforeAll(async () => { + container = await new GenericContainer("redis:8.8-alpine") + .withExposedPorts(6379) + .start(); + redis = new IORedis({ + host: container.getHost(), + port: container.getMappedPort(6379), + }); + + db = await bootMigratedDb("ChatBlocksTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561192820000000n); + + const notifications = new NotificationsService( + hasura as any, + postgres, + logger as any, + { get: () => ({ webDomain: "https://example.com" }) } as any, + new NotificationPreferencesService(postgres), + { + filterSubscribed: async (): Promise => [], + claimFanOut: jest.fn(), + } as any, + { add: jest.fn() } as any, + { add: jest.fn() } as any, + ); + + blocks = new PlayerBlocksService(postgres); + + chat = new ChatService( + logger as any, + {} as any, + hasura as any, + postgres, + { getConnection: () => redis } as any, + notifications, + blocks, + ); + }, 600_000); + + afterAll(async () => { + redis?.disconnect(); + await container?.stop(); + await db?.stop(); + }); + + beforeEach(async () => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + await redis.flushall(); + await postgres.query("DELETE FROM notifications"); + await postgres.query("DELETE FROM chat_message_edits"); + await postgres.query("DELETE FROM chat_message_deletions"); + await postgres.query("DELETE FROM direct_messages"); + await postgres.query("DELETE FROM direct_conversations"); + await postgres.query("DELETE FROM players"); + + roster = []; + friendshipOverride = false; + beforeBellInsert = undefined; + + to = jest.spyOn(chat as any, "to"); + notify = jest.spyOn(chat as any, "notifyLobbyMembers"); + resend = jest.spyOn(chat as any, "resendHistory"); + deliver = jest.spyOn(chat as any, "deliverDirectMessage"); + publish = jest.spyOn(redis, "publish"); + }); + + // Every broadcast and notification is fire-and-forget; this waits for the + // ones started so far. + const settle = async () => { + for (let pending = 0; pending !== inFlight().length; ) { + pending = inFlight().length; + await Promise.allSettled(inFlight()); + } + }; + + const inFlight = () => + [to, notify, resend, deliver].flatMap((spy) => + spy.mock.results.map(({ value }) => value), + ); + + const block = (blocker: string, blocked: string) => + runAsUser(postgres, blocker, "user", (query) => + query( + `INSERT INTO player_blocks (blocker_steam_id, blocked_steam_id) + VALUES ($1::bigint, $2::bigint)`, + [blocker, blocked], + ), + ); + + const unblock = (blocker: string, blocked: string) => + runAsUser(postgres, blocker, "user", (query) => + query( + `DELETE FROM player_blocks + WHERE blocker_steam_id = $1::bigint + AND blocked_steam_id = $2::bigint`, + [blocker, blocked], + ), + ); + + const player = (steamId: string) => + ({ steam_id: steamId, name: "Someone", role: "user" }) as any; + + const socket = (steamId: string) => { + const sent: Array<{ event: string; data: any }> = []; + + return { + id: randomUUID(), + user: { steam_id: steamId }, + send: (payload: string) => sent.push(JSON.parse(payload)), + on: jest.fn(), + sent, + } as any; + }; + + const join = async (type: ChatLobbyType, id: string, steamId: string) => { + const client = socket(steamId); + + await chat.joinMatchLobby(client, type, id); + await settle(); + + return client.sent.find( + ({ event }: { event: string }) => + event === `lobby:${type}:${id}:messages`, + )?.data.messages as Array<{ message: string }> | undefined; + }; + + const historyOf = async (type: ChatLobbyType, id: string, steamId: string) => + (await join(type, id, steamId))?.map(({ message }) => message); + + const say = async ( + type: ChatLobbyType, + id: string, + steamId: string, + text: string, + ) => { + const result = await chat.sendMessageToChat( + type, + id, + player(steamId), + text, + ); + await settle(); + + return result; + }; + + const delivered = (event: string) => + publish.mock.calls + .filter(([channel]) => channel === "send-message-to-steam-id") + .map(([, payload]) => JSON.parse(payload)) + .filter((sent) => sent.event === event); + + const recipientsOf = (event: string, messageId: string) => + delivered(event) + .filter(({ data }) => data.id === messageId) + .map(({ steamId }) => steamId) + .sort(); + + const bell = (messageId: string) => + postgres.query< + Array<{ steam_id: string; message: string; deleted: boolean }> + >( + `SELECT steam_id::text AS steam_id, message, + deleted_at IS NOT NULL AS deleted + FROM notifications + WHERE data->>'messageId' = $1 + ORDER BY steam_id`, + [messageId], + ); + + const previews = async (messageId: string) => + Object.fromEntries( + (await bell(messageId)).map(({ steam_id, message }) => [ + steam_id, + message, + ]), + ); + + describe("a group room", () => { + let blocker: string; + let blocked: string; + let bystander: string; + let matchId: string; + + const inMatch = (steamId: string, text: string) => + say(ChatLobbyType.Match, matchId, steamId, text); + + const messageIdOf = (result: Awaited>) => + result.accepted ? result.messageId : ""; + + beforeEach(async () => { + blocker = await fx.player("Blocker"); + blocked = await fx.player("Blocked"); + bystander = await fx.player("Bystander"); + roster = [blocker, blocked, bystander]; + matchId = randomUUID(); + + for (const steamId of roster) { + await join(ChatLobbyType.Match, matchId, steamId); + } + }); + + it("hides what the blocked player says from the blocker, live and in history, and from nobody else", async () => { + const before = messageIdOf(await inMatch(blocked, "before")); + + expect(recipientsOf(`lobby:match:${matchId}:chat`, before)).toEqual( + [...roster].sort(), + ); + + await block(blocker, blocked); + + const after = messageIdOf(await inMatch(blocked, "after")); + + expect(recipientsOf(`lobby:match:${matchId}:chat`, after)).toEqual( + [blocked, bystander].sort(), + ); + + expect(await historyOf(ChatLobbyType.Match, matchId, blocker)).toEqual( + [], + ); + expect(await historyOf(ChatLobbyType.Match, matchId, bystander)).toEqual([ + "before", + "after", + ]); + expect(await historyOf(ChatLobbyType.Match, matchId, blocked)).toEqual([ + "before", + "after", + ]); + + const reply = messageIdOf(await inMatch(blocker, "from the blocker")); + + expect(recipientsOf(`lobby:match:${matchId}:chat`, reply)).toEqual( + [...roster].sort(), + ); + }); + + it("keeps an edit to a hidden line from the blocker", async () => { + await block(blocker, blocked); + + const id = messageIdOf(await inMatch(blocked, "typo")); + + await expect( + chat.editMessage( + ChatLobbyType.Match, + matchId, + id, + player(blocked), + "fixed", + ), + ).resolves.toMatchObject({ edited: true }); + await settle(); + + expect(recipientsOf(`lobby:match:${matchId}:edited`, id)).toEqual( + [blocked, bystander].sort(), + ); + }); + + it("writes the blocker no bell row for a hidden line, and blanks the ones from before", async () => { + const before = messageIdOf(await inMatch(blocked, "before")); + + expect(await previews(before)).toEqual({ + [blocker]: "before", + [bystander]: "before", + }); + + await block(blocker, blocked); + + expect(await bell(before)).toContainEqual({ + steam_id: blocker, + message: "", + deleted: true, + }); + expect(await bell(before)).toContainEqual({ + steam_id: bystander, + message: "before", + deleted: false, + }); + + const after = messageIdOf(await inMatch(blocked, "after")); + + expect(await previews(after)).toEqual({ [bystander]: "after" }); + + await chat.editMessage( + ChatLobbyType.Match, + matchId, + before, + player(blocked), + "before, edited", + ); + await settle(); + + expect(await previews(before)).toEqual({ + [blocker]: "", + [bystander]: "before, edited", + }); + }); + + it("blanks the blocker's row for a line whose rows were aimed before the block landed", async () => { + beforeBellInsert = () => block(blocker, blocked); + + const id = messageIdOf(await inMatch(blocked, "racing")); + + expect(await previews(id)).toEqual({ + [blocker]: "", + [bystander]: "racing", + }); + expect(await bell(id)).toContainEqual({ + steam_id: blocker, + message: "", + deleted: true, + }); + }); + + it("leaves the bell alone for everyone when the blocker is the one talking", async () => { + await block(blocker, blocked); + + const id = messageIdOf(await inMatch(blocker, "hello")); + + expect((await bell(id)).map(({ steam_id }) => steam_id)).toEqual( + [blocked, bystander].sort(), + ); + }); + + it("gives back lines that are still live once unblocked", async () => { + await block(blocker, blocked); + await inMatch(blocked, "while blocked"); + + expect(await historyOf(ChatLobbyType.Match, matchId, blocker)).toEqual( + [], + ); + + await unblock(blocker, blocked); + + expect(await historyOf(ChatLobbyType.Match, matchId, blocker)).toEqual([ + "while blocked", + ]); + + const after = messageIdOf(await inMatch(blocked, "after")); + + expect(recipientsOf(`lobby:match:${matchId}:chat`, after)).toEqual( + [...roster].sort(), + ); + }); + + it("re-sends a draft's history to each player without what they blocked when it moves into the match", async () => { + const draftId = randomUUID(); + + for (const [steamId, text] of [ + [blocked, "draft from blocked"], + [bystander, "draft from bystander"], + ]) { + await chat.sendMessageToChat( + ChatLobbyType.Draft, + draftId, + player(steamId), + text, + true, + ); + } + await settle(); + + await block(blocker, blocked); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + draftId, + ChatLobbyType.Match, + matchId, + ); + await settle(); + + const resent = Object.fromEntries( + delivered(`lobby:match:${matchId}:messages`).map( + ({ steamId, data }) => [ + steamId, + data.messages.map(({ message }: { message: string }) => message), + ], + ), + ); + + expect(resent).toEqual({ + [blocker]: ["draft from bystander"], + [blocked]: ["draft from blocked", "draft from bystander"], + [bystander]: ["draft from blocked", "draft from bystander"], + }); + expect(await historyOf(ChatLobbyType.Match, matchId, blocker)).toEqual([ + "draft from bystander", + ]); + }); + }); + + describe("a direct conversation", () => { + let blocker: string; + let blocked: string; + let room: string; + let fromBlocker: string; + let fromBlocked: string; + + const rail = async (steamId: string) => + (await chat.getDirectConversations(player(steamId))).map( + ({ roomId }) => roomId, + ); + + beforeEach(async () => { + blocker = await fx.player("Blocker"); + blocked = await fx.player("Blocked"); + room = directRoomId(blocker, blocked); + + await postgres.query( + `INSERT INTO friends (player_steam_id, other_player_steam_id, status) + VALUES ($1::bigint, $2::bigint, 'Accepted')`, + [blocker, blocked], + ); + + await join(ChatLobbyType.Direct, room, blocker); + await join(ChatLobbyType.Direct, room, blocked); + + const first = await say(ChatLobbyType.Direct, room, blocker, "hi"); + const second = await say(ChatLobbyType.Direct, room, blocked, "hello"); + + fromBlocker = first.accepted ? first.messageId : ""; + fromBlocked = second.accepted ? second.messageId : ""; + }); + + const refusesEverything = async () => { + publish.mockClear(); + + for (const steamId of [blocker, blocked]) { + const own = steamId === blocker ? fromBlocker : fromBlocked; + const theirs = steamId === blocker ? fromBlocked : fromBlocker; + + await expect( + say(ChatLobbyType.Direct, room, steamId, "still there?"), + ).resolves.toEqual({ + accepted: false, + code: ChatErrorCode.NotAllowed, + }); + + expect(await join(ChatLobbyType.Direct, room, steamId)).toBeUndefined(); + + await expect( + chat.editMessage( + ChatLobbyType.Direct, + room, + own, + player(steamId), + "edited", + ), + ).resolves.toEqual({ edited: false, code: ChatErrorCode.NotAllowed }); + + await expect( + chat.deleteMessage(ChatLobbyType.Direct, room, own, player(steamId)), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + await expect( + chat.toggleReaction( + ChatLobbyType.Direct, + room, + theirs, + "heart", + player(steamId), + ), + ).resolves.toEqual({ toggled: false, code: ChatErrorCode.NotAllowed }); + + await expect( + chat.markThreadRead(ChatLobbyType.Direct, room, player(steamId)), + ).resolves.toBeNull(); + } + + await settle(); + + expect(publish).not.toHaveBeenCalled(); + + const messages = await postgres.query< + Array<{ message: string; edited: boolean; reactions: number }> + >( + `SELECT dm.message, dm.edited_at IS NOT NULL AS edited, + (SELECT count(*)::int FROM direct_message_reactions r + WHERE r.message_id = dm.id) AS reactions + FROM direct_messages dm + WHERE dm.room_id = $1 + ORDER BY dm.created_at`, + [room], + ); + expect(messages).toEqual([ + { message: "hi", edited: false, reactions: 0 }, + { message: "hello", edited: false, reactions: 0 }, + ]); + + const [{ reads }] = await postgres.query>( + `SELECT count(*)::int AS reads FROM chat_read_state WHERE thread = $1`, + [`chat:direct:${room}`], + ); + expect(reads).toBe(0); + }; + + it("refuses both sides once one of them blocks", async () => { + await postgres.query("DELETE FROM chat_read_state"); + await block(blocker, blocked); + + await refusesEverything(); + }); + + it("refuses both sides on the block alone, while a friendship still reads as accepted", async () => { + await postgres.query("DELETE FROM chat_read_state"); + await block(blocked, blocker); + friendshipOverride = true; + + await refusesEverything(); + }); + + it("writes, reopens and delivers nothing for a block that lands after the send's access check", async () => { + await block(blocker, blocked); + await postgres.query( + `UPDATE direct_conversations SET is_open = false WHERE room_id = $1`, + [room], + ); + friendshipOverride = true; + jest.spyOn(blocks, "isBlockedEitherWay").mockResolvedValueOnce(false); + publish.mockClear(); + + await expect( + say(ChatLobbyType.Direct, room, blocked, "sneaking in"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.NotAllowed }); + + expect(publish).not.toHaveBeenCalled(); + + const [{ count, open }] = await postgres.query< + Array<{ count: number; open: number }> + >( + `SELECT (SELECT count(*)::int FROM direct_messages + WHERE room_id = $1) AS count, + (SELECT count(*)::int FROM direct_conversations + WHERE room_id = $1 AND is_open) AS open`, + [room], + ); + expect({ count, open }).toEqual({ count: 2, open: 0 }); + }); + + it("takes the conversation off the blocker's rail only, and gives it back on unblock", async () => { + expect(await rail(blocker)).toEqual([room]); + + await block(blocker, blocked); + + expect(await rail(blocker)).toEqual([]); + expect(await rail(blocked)).toEqual([room]); + + await unblock(blocker, blocked); + + expect(await rail(blocker)).toEqual([room]); + }); + + it("blanks the blocker's bell rows for the blocked player's messages, and never the other way", async () => { + await block(blocker, blocked); + + expect(await bell(fromBlocked)).toEqual([ + { steam_id: blocker, message: "", deleted: true }, + ]); + expect(await bell(fromBlocker)).toEqual([ + { steam_id: blocked, message: "hi", deleted: false }, + ]); + }); + }); +}); diff --git a/test/chat-direct-messages.spec.ts b/test/chat-direct-messages.spec.ts index f179ba43..3638a5a6 100644 --- a/test/chat-direct-messages.spec.ts +++ b/test/chat-direct-messages.spec.ts @@ -4,6 +4,7 @@ import { PostgresService } from "./../src/postgres/postgres.service"; import { Fixtures } from "./utils/fixtures"; import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; import { ChatService } from "./../src/chat/chat.service"; +import { PlayerBlocksService } from "./../src/player-blocks/player-blocks.service"; import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; import { NotificationsService } from "./../src/notifications/notifications.service"; @@ -71,9 +72,12 @@ describe("direct messages (SQL-driven)", () => { collapseOlderUnread: jest.fn(), retractChatMessage: (messageId: string) => bell.retractChatMessage(messageId), + retractChatMessageFromBlocked: (messageId: string) => + bell.retractChatMessageFromBlocked(messageId), updateChatMessagePreview: (messageId: string, preview: string) => bell.updateChatMessagePreview(messageId, preview), } as any, + new PlayerBlocksService(postgres), ); }, 600_000); diff --git a/test/chat-moderation.spec.ts b/test/chat-moderation.spec.ts index 0af53e13..67b10cfd 100644 --- a/test/chat-moderation.spec.ts +++ b/test/chat-moderation.spec.ts @@ -8,6 +8,7 @@ import { PostgresService } from "./../src/postgres/postgres.service"; import { Fixtures } from "./utils/fixtures"; import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; import { ChatService } from "./../src/chat/chat.service"; +import { PlayerBlocksService } from "./../src/player-blocks/player-blocks.service"; import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; import { NotificationsService } from "./../src/notifications/notifications.service"; @@ -155,6 +156,7 @@ describe("chat moderation (SQL-driven)", () => { postgres, { getConnection: () => redis } as any, notifications, + new PlayerBlocksService(postgres), ); }, 600_000); diff --git a/test/chat-redis-actions.spec.ts b/test/chat-redis-actions.spec.ts index ad0a5e02..c534523a 100644 --- a/test/chat-redis-actions.spec.ts +++ b/test/chat-redis-actions.spec.ts @@ -7,6 +7,7 @@ import { PostgresService } from "./../src/postgres/postgres.service"; import { Fixtures } from "./utils/fixtures"; import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; import { ChatService } from "./../src/chat/chat.service"; +import { PlayerBlocksService } from "./../src/player-blocks/player-blocks.service"; import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; import { NotificationsService } from "./../src/notifications/notifications.service"; @@ -88,6 +89,7 @@ describe("chat edits and self deletes (SQL-driven)", () => { postgres, { getConnection: () => redis } as any, notifications, + new PlayerBlocksService(postgres), ); }, 600_000); diff --git a/test/player-blocks.spec.ts b/test/player-blocks.spec.ts index ebe31a37..0a6dbe1a 100644 --- a/test/player-blocks.spec.ts +++ b/test/player-blocks.spec.ts @@ -906,6 +906,12 @@ describe("player blocks (SQL-driven)", () => { expect(await service.hasBlocked(b, a)).toBe(false); expect(await service.blockedBy(a)).toEqual(new Set([b])); expect(await service.filterUnblocked(a, [c, b, d, a])).toEqual([d, a]); + expect(await service.blockedAmong([a, b, c, d], [a, b])).toEqual( + new Map([ + [a, new Set([b])], + [c, new Set([a])], + ]), + ); }); });