diff --git a/hasura/enums/sanction-types.sql b/hasura/enums/sanction-types.sql index bf6877ce..217f064e 100644 --- a/hasura/enums/sanction-types.sql +++ b/hasura/enums/sanction-types.sql @@ -1,7 +1,7 @@ insert into e_sanction_types ("value", "description") values ('ban', 'Player is not able to participate in any activity'), ('mute', 'Player cannot use voice chat in game'), - ('gag', 'Player cannot use text chat in game'), + ('gag', 'Player cannot use text chat in game or in website lobby chat'), ('silence', 'Player muted and gagged'), ('warning', 'Informational note on the player''s record; never enforced, never expires') on conflict(value) do update set "description" = EXCLUDED."description" diff --git a/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml b/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml new file mode 100644 index 00000000..310fe26c --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_chat_message_deletions.yaml @@ -0,0 +1,45 @@ +table: + name: chat_message_deletions + schema: public +object_relationships: + - name: author + using: + foreign_key_constraint_on: author_steam_id + - name: deleted_by + using: + foreign_key_constraint_on: deleted_by_steam_id +select_permissions: + - role: match_organizer + permission: + columns: + - id + - message_id + - room_type + - room_id + - author_steam_id + - message + - message_created_at + - source + - deleted_by_steam_id + - deleted_at + filter: {} + allow_aggregations: true + comment: Evidence of moderated website chat. Written only by the API. + - role: moderator + permission: + columns: + - id + - message_id + - room_type + - room_id + - author_steam_id + - message + - message_created_at + - source + - deleted_by_steam_id + - deleted_at + filter: + room_type: + _neq: organizers + allow_aggregations: true + comment: The organizers' room is closed to moderators, and so is its evidence. diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 2ae6e1fc..32afb034 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -5,6 +5,7 @@ - "!include public_award_recipients.yaml" - "!include public_awards.yaml" - "!include public_broadcast_huds.yaml" +- "!include public_chat_message_deletions.yaml" - "!include public_chat_read_state.yaml" - "!include public_clip_render_jobs.yaml" - "!include public_custom_pages.yaml" diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql new file mode 100644 index 00000000..a7627784 --- /dev/null +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS public.notifications_message_id_idx; +DROP TABLE IF EXISTS public.chat_message_deletions; diff --git a/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql new file mode 100644 index 00000000..62df8365 --- /dev/null +++ b/hasura/migrations/default/1888000000100_chat_message_deletions/up.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS public.chat_message_deletions ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + message_id uuid NOT NULL, + room_type text NOT NULL, + room_id text NOT NULL, + author_steam_id bigint REFERENCES public.players (steam_id) + ON UPDATE CASCADE ON DELETE SET NULL, + message text NOT NULL, + message_created_at timestamptz, + source text, + deleted_by_steam_id bigint REFERENCES public.players (steam_id) + ON UPDATE CASCADE ON DELETE SET NULL, + deleted_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (id), + UNIQUE (message_id, room_type, room_id) +); + +CREATE INDEX IF NOT EXISTS chat_message_deletions_author_idx + ON public.chat_message_deletions (author_steam_id, deleted_at DESC); + +CREATE INDEX IF NOT EXISTS notifications_message_id_idx + ON public.notifications ((data->>'messageId')) + WHERE data->>'messageId' IS NOT NULL; diff --git a/src/chat/chat.gateway.spec.ts b/src/chat/chat.gateway.spec.ts index f7e173aa..730692d8 100644 --- a/src/chat/chat.gateway.spec.ts +++ b/src/chat/chat.gateway.spec.ts @@ -117,7 +117,12 @@ describe("ChatGateway lobby:chat", () => { expect(sent(socket)).toEqual([ { event: "chat:error", - data: { code: ChatErrorCode.TooLong, max: 2000, requestId: "r-1" }, + data: { + code: ChatErrorCode.TooLong, + action: "send", + max: 2000, + requestId: "r-1", + }, }, ]); }); @@ -135,7 +140,10 @@ describe("ChatGateway lobby:chat", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:error", data: { code: "too_long", max: 2000 } }, + { + event: "chat:error", + data: { code: "too_long", action: "send", max: 2000 }, + }, ]); }); @@ -180,7 +188,37 @@ describe("ChatGateway lobby:chat", () => { expect(sent(socket)).toEqual([ { event: "chat:error", - data: { code: ChatErrorCode.NotAllowed, requestId: "r-2" }, + data: { + code: ChatErrorCode.NotAllowed, + action: "send", + requestId: "r-2", + }, + }, + ]); + }); + + it("tells a gagged sender why, and never relays them", async () => { + chat.sendMessageToChat.mockResolvedValue({ + accepted: false, + code: ChatErrorCode.Gagged, + }); + const socket = client(); + + await gateway.lobby( + { + id: "m-1", + type: ChatLobbyType.Match, + message: "gg", + requestId: "r-4", + }, + socket, + ); + + expect(chat.sendChatToServer).not.toHaveBeenCalled(); + expect(sent(socket)).toEqual([ + { + event: "chat:error", + data: { code: "gagged", action: "send", requestId: "r-4" }, }, ]); }); @@ -260,7 +298,10 @@ describe("ChatGateway lobby:chat", () => { ); expect(sent(socket)).toEqual([ - { event: "chat:ack", data: { requestId: "r-3", messageId: "msg-1" } }, + { + event: "chat:ack", + data: { requestId: "r-3", messageId: "msg-1", action: "send" }, + }, ]); }); @@ -276,3 +317,147 @@ describe("ChatGateway lobby:chat", () => { }); }); }); + +describe("ChatGateway lobby:delete", () => { + const MESSAGE_ID = "3f0c1d2e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + let chat: { deleteMessage: jest.Mock }; + let gateway: ChatGateway; + + const client = ( + user: any = { steam_id: "1", name: "Mod", role: "moderator" }, + ) => ({ id: "client-1", user, send: jest.fn() }) as any; + + const sent = (socket: { send: jest.Mock }) => + socket.send.mock.calls.map(([raw]) => JSON.parse(raw)); + + beforeEach(() => { + chat = { deleteMessage: jest.fn().mockResolvedValue({ deleted: true }) }; + gateway = new ChatGateway(chat as any); + }); + + it("ignores a socket that has not signed in", async () => { + const socket = client(null); + + await gateway.deleteMessage( + { + id: "m-1", + type: ChatLobbyType.Match, + messageId: MESSAGE_ID, + requestId: "r-1", + }, + socket, + ); + + expect(chat.deleteMessage).not.toHaveBeenCalled(); + expect(socket.send).not.toHaveBeenCalled(); + }); + + it.each([ + ["an unknown lobby type", { type: "global", id: "m-1", messageId: "x" }], + [ + "a room id that is not a string", + { type: "match", id: 1, messageId: "x" }, + ], + ["a missing message id", { type: "match", id: "m-1" }], + ["a missing payload", undefined], + ])("ignores %s", async (_, data) => { + const socket = client(); + + await gateway.deleteMessage(data as any, socket); + + expect(chat.deleteMessage).not.toHaveBeenCalled(); + expect(socket.send).not.toHaveBeenCalled(); + }); + + it("asks the service to delete as the signed in player", async () => { + await gateway.deleteMessage( + { id: "m-1", type: ChatLobbyType.Match, messageId: MESSAGE_ID }, + client(), + ); + + expect(chat.deleteMessage).toHaveBeenCalledWith( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + expect.objectContaining({ steam_id: "1" }), + ); + }); + + it("acks a deletion under the requestId it came with", async () => { + const socket = client(); + + await gateway.deleteMessage( + { + id: "m-1", + type: ChatLobbyType.Match, + messageId: MESSAGE_ID, + requestId: "r-2", + }, + socket, + ); + + expect(sent(socket)).toEqual([ + { + event: "chat:ack", + data: { requestId: "r-2", messageId: MESSAGE_ID, action: "delete" }, + }, + ]); + }); + + it("stays quiet for a deletion without a requestId", async () => { + const socket = client(); + + await gateway.deleteMessage( + { id: "m-1", type: ChatLobbyType.Match, messageId: MESSAGE_ID }, + socket, + ); + + expect(socket.send).not.toHaveBeenCalled(); + }); + + it.each([ChatErrorCode.NotAllowed, ChatErrorCode.NotFound])( + "reports %s under the requestId it came with", + async (code) => { + chat.deleteMessage.mockResolvedValue({ deleted: false, code }); + const socket = client(); + + await gateway.deleteMessage( + { + id: "m-1", + type: ChatLobbyType.Match, + messageId: MESSAGE_ID, + requestId: "r-3", + }, + socket, + ); + + expect(sent(socket)).toEqual([ + { + event: "chat:error", + data: { code, action: "delete", requestId: "r-3" }, + }, + ]); + }, + ); + + it("still reports a refusal without a requestId", async () => { + chat.deleteMessage.mockResolvedValue({ + deleted: false, + code: ChatErrorCode.NotAllowed, + }); + const socket = client(); + + await gateway.deleteMessage( + { id: "x", type: ChatLobbyType.Direct, messageId: MESSAGE_ID }, + socket, + ); + + expect(sent(socket)).toEqual([ + { + event: "chat:error", + data: { code: "not_allowed", action: "delete" }, + }, + ]); + }); +}); diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 52cc01ee..6a9e58c3 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -8,6 +8,7 @@ import { ChatService } from "./chat.service"; import { FiveStackWebSocketClient } from "src/sockets/types/FiveStackWebSocketClient"; import { ChatLobbyType } from "./enums/ChatLobbyTypes"; import { ChatErrorCode } from "./enums/ChatErrorCode"; +import { ChatAction } from "./types/ChatAction"; import { isRoleAbove } from "@utilities/isRoleAbove"; @WebSocketGateway({ @@ -112,7 +113,7 @@ export class ChatGateway { if ("error" in parsed) { if (parsed.error === ChatErrorCode.TooLong) { - this.sendError(client, parsed.error, requestId); + this.sendError(client, "send", parsed.error, requestId); } return; } @@ -129,13 +130,13 @@ export class ChatGateway { // any signed-in socket print into any live match. if (result.accepted === false) { if (result.code) { - this.sendError(client, result.code, requestId); + this.sendError(client, "send", result.code, requestId); } return; } if (requestId) { - this.sendAck(client, requestId, result.messageId); + this.sendAck(client, "send", requestId, result.messageId); } if (data.type !== ChatLobbyType.Match) { @@ -151,12 +152,56 @@ export class ChatGateway { ); } + @SubscribeMessage("lobby:delete") + async deleteMessage( + @MessageBody() + data: { + id: string; + type: ChatLobbyType; + messageId: string; + requestId?: string; + }, + @ConnectedSocket() client: FiveStackWebSocketClient, + ) { + if (!client.user) { + return; + } + + if ( + !ChatGateway.isLobbyType(data?.type) || + typeof data.id !== "string" || + typeof data.messageId !== "string" + ) { + return; + } + + const requestId = + typeof data.requestId === "string" ? data.requestId : undefined; + + const result = await this.chat.deleteMessage( + data.type, + data.id, + data.messageId, + client.user, + ); + + if (result.deleted === false) { + this.sendError(client, "delete", result.code, requestId); + return; + } + + if (requestId) { + this.sendAck(client, "delete", requestId, data.messageId); + } + } + private static isLobbyType(value: unknown): value is ChatLobbyType { return Object.values(ChatLobbyType).includes(value as ChatLobbyType); } private sendError( client: FiveStackWebSocketClient, + action: ChatAction, code: ChatErrorCode, requestId?: string, ) { @@ -165,6 +210,7 @@ export class ChatGateway { event: "chat:error", data: { code, + action, ...(code === ChatErrorCode.TooLong ? { max: ChatService.MAX_MESSAGE_LENGTH } : {}), @@ -176,13 +222,14 @@ export class ChatGateway { private sendAck( client: FiveStackWebSocketClient, + action: ChatAction, requestId: string, messageId: string, ) { client.send( JSON.stringify({ event: "chat:ack", - data: { requestId, messageId }, + data: { requestId, messageId, action }, }), ); } diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 82bf9cfa..196753cd 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -36,13 +36,31 @@ describe("ChatService direct messages", () => { let acceptedFriendships: Array<[string, string]>; let role: string; let queries: Array<{ sql: string; bindings: any[] }>; + let gagged: boolean; + let audited: boolean; const postgres = { query: jest.fn(async (sql: string, bindings: any[]): Promise => { queries.push({ sql, bindings }); + + if (sql.includes("public.is_gagged")) { + return [{ gagged }]; + } + + if (sql.includes("SELECT 1 FROM public.chat_message_deletions")) { + return [{ deleted: audited }]; + } + return []; }), }; + const notifications = { + notifyPlayers: jest.fn(), + collapseOlderUnread: jest.fn(), + markConversationRead: jest.fn(), + retractChatMessage: jest.fn().mockResolvedValue(undefined), + }; + const client = (steamId: string) => ({ id: "client-1", @@ -235,7 +253,9 @@ describe("ChatService direct messages", () => { // clearAllMocks keeps implementations, so a test that seats someone in a // room would otherwise leave them seated for every test after it. redis.hget.mockResolvedValue(null); + redis.hgetall.mockResolvedValue({}); redis.get.mockResolvedValue(null); + notifications.retractChatMessage.mockResolvedValue(undefined); acceptedFriendships = [[ME, FRIEND]]; myMatches = ["m-1"]; otherMatches = ["mm-1"]; @@ -248,6 +268,8 @@ describe("ChatService direct messages", () => { staff = []; role = "user"; queries = []; + gagged = false; + audited = false; rcon.send.mockResolvedValue(undefined); rcon.connect.mockResolvedValue(rcon); @@ -257,7 +279,7 @@ describe("ChatService direct messages", () => { hasuraService as any, postgres as any, { getConnection: () => redis } as any, - { notifyPlayers: jest.fn(), markConversationRead: jest.fn() } as any, + notifications as any, ); }); @@ -839,6 +861,436 @@ describe("ChatService direct messages", () => { }); }); + describe("gag", () => { + const player = () => + ({ steam_id: ME, name: "Someone", role: "user" }) as any; + + const groupRooms = [ + ChatLobbyType.Match, + ChatLobbyType.MatchTeam, + ChatLobbyType.MatchMaking, + ChatLobbyType.Tournament, + ChatLobbyType.Draft, + ChatLobbyType.Organizer, + ChatLobbyType.Team, + ]; + + beforeEach(() => { + gagged = true; + }); + + it.each(groupRooms)( + "keeps a gagged player's website message out of a %s room", + async (type) => { + await expect( + service.sendMessageToChat(type, "x", player(), "hello", true), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.Gagged }); + + expect(redis.hset).not.toHaveBeenCalled(); + expect(redis.publish).not.toHaveBeenCalled(); + expect(notifications.notifyPlayers).not.toHaveBeenCalled(); + }, + ); + + it("refuses a gagged player in a room they belong to", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); + + await expect( + service.sendMessageToChat(ChatLobbyType.Match, "m-1", player(), "hi"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.Gagged }); + + const [check] = queries.filter(({ sql }) => + sql.includes("public.is_gagged"), + ); + + expect(check.bindings).toEqual([ME]); + }); + + it("answers not_allowed rather than gagged for a room they are not in", async () => { + await expect( + service.sendMessageToChat(ChatLobbyType.Match, "m-2", player(), "hi"), + ).resolves.toEqual({ accepted: false, code: ChatErrorCode.NotAllowed }); + }); + + it("leaves a gagged player's direct messages alone", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); + + await expect( + service.sendMessageToChat( + ChatLobbyType.Direct, + directRoomId(ME, FRIEND), + player(), + "hi", + ), + ).resolves.toEqual({ accepted: true, messageId: expect.any(String) }); + + expect(queries.some(({ sql }) => sql.includes("public.is_gagged"))).toBe( + false, + ); + }); + + it("leaves a line relayed from the game to the game server's gag", async () => { + await expect( + service.sendMessageToChat( + ChatLobbyType.Match, + "m-1", + player(), + "from the server", + true, + "game", + ), + ).resolves.toEqual({ accepted: true, messageId: expect.any(String) }); + }); + + it("lets a player post once the gag is lifted", async () => { + gagged = false; + + await expect( + service.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + player(), + "back", + true, + ), + ).resolves.toEqual({ accepted: true, messageId: expect.any(String) }); + }); + }); + + describe("deleting", () => { + const MESSAGE_ID = "3f0c1d2e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + const moderator = (overrides: Record = {}) => + ({ steam_id: ME, name: "Mod", role: "moderator", ...overrides }) as any; + + let stored: Record>; + + const store = ( + type: ChatLobbyType, + id: string, + message: Record = {}, + ) => { + stored[`chat_${type}_${id}`] = { + ...stored[`chat_${type}_${id}`], + [MESSAGE_ID]: JSON.stringify({ + id: MESSAGE_ID, + message: "something awful", + timestamp: "2025-01-01T00:00:00.000Z", + source: "web", + from: { role: "user", name: "Author", steam_id: FRIEND }, + ...message, + }), + }; + }; + + const audits = () => + queries.filter(({ sql }) => + sql.includes("INSERT INTO public.chat_message_deletions"), + ); + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + beforeEach(() => { + role = "moderator"; + stored = {}; + redis.hget.mockImplementation( + async (key: string, field: string) => stored[key]?.[field] ?? null, + ); + }); + + it("lets a moderator remove a message from a room they are in, however old", async () => { + store(ChatLobbyType.Match, "m-1"); + redis.hgetall.mockImplementation(async (key: string) => + key === "chat:match:m-1" + ? { [FRIEND]: JSON.stringify({ user: { steam_id: FRIEND } }) } + : {}, + ); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: true }); + + expect(redis.hdel).toHaveBeenCalledWith("chat_match_m-1", MESSAGE_ID); + + await flush(); + + const broadcast = redis.publish.mock.calls + .map(([, payload]) => JSON.parse(payload)) + .find(({ event }) => event === "lobby:match:m-1:deleted"); + + expect(broadcast).toEqual({ + steamId: FRIEND, + event: "lobby:match:m-1:deleted", + data: { id: MESSAGE_ID }, + }); + }); + + it("keeps the evidence of what was removed", async () => { + store(ChatLobbyType.Match, "m-1"); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + expect(audits().at(0)?.bindings).toEqual([ + MESSAGE_ID, + "match", + "m-1", + FRIEND, + "something awful", + "2025-01-01T00:00:00.000Z", + "web", + ME, + ]); + }); + + it("writes the audit row before the message is removed", async () => { + store(ChatLobbyType.Match, "m-1"); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + const auditCall = postgres.query.mock.calls.findIndex(([sql]) => + sql.includes("INSERT INTO public.chat_message_deletions"), + ); + + expect(postgres.query.mock.invocationCallOrder[auditCall]).toBeLessThan( + redis.hdel.mock.invocationCallOrder[0], + ); + }); + + it("leaves the message in place when the audit row cannot be written", async () => { + store(ChatLobbyType.Match, "m-1"); + postgres.query.mockRejectedValueOnce(new Error("database down")); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).rejects.toThrow("database down"); + + expect(redis.hdel).not.toHaveBeenCalled(); + expect(notifications.retractChatMessage).not.toHaveBeenCalled(); + }); + + it("records no author for a steam id stored as a number", async () => { + store(ChatLobbyType.Match, "m-1", { + from: { role: "user", name: "Author", steam_id: 76561198000000002 }, + }); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + expect(audits().at(0)?.bindings[3]).toBeNull(); + }); + + it("retracts the message's notifications", async () => { + store(ChatLobbyType.Match, "m-1"); + + await service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ); + + expect(notifications.retractChatMessage).toHaveBeenCalledWith(MESSAGE_ID); + }); + + it("still deletes when the retraction fails", async () => { + store(ChatLobbyType.Match, "m-1"); + notifications.retractChatMessage.mockRejectedValue(new Error("nope")); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: true }); + }); + + it("refuses a streamer", async () => { + role = "streamer"; + store(ChatLobbyType.Match, "m-1"); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator({ role: "streamer" }), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(audits()).toHaveLength(0); + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("goes by the role on record, not the one the socket signed in with", async () => { + role = "user"; + store(ChatLobbyType.Match, "m-1"); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator({ role: "administrator" }), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("refuses a moderator in a room they cannot get into", async () => { + store(ChatLobbyType.Match, "m-2"); + + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-2", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("refuses anyone in a direct conversation", async () => { + role = "administrator"; + + await expect( + service.deleteMessage( + ChatLobbyType.Direct, + directRoomId(ME, FRIEND), + MESSAGE_ID, + moderator({ role: "administrator" }), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotAllowed }); + + expect(redis.hget).not.toHaveBeenCalled(); + expect(queries).toHaveLength(0); + }); + + it("answers not_found for a message that is not there", async () => { + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + MESSAGE_ID, + moderator(), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotFound }); + + expect(audits()).toHaveLength(0); + expect(redis.hdel).not.toHaveBeenCalled(); + }); + + it("answers not_found for an id that could never be a message", async () => { + await expect( + service.deleteMessage( + ChatLobbyType.Match, + "m-1", + "not-a-uuid", + moderator(), + ), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotFound }); + + expect(redis.hget).not.toHaveBeenCalled(); + }); + + describe("while its notifications are still being written", () => { + const sayInTournament = async () => { + tournament.roster = [ME, FRIEND]; + redis.hget.mockResolvedValue( + JSON.stringify({ user: { steam_id: ME } }), + ); + role = "user"; + + const result = await service.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + { steam_id: ME, name: "Someone", role: "user" } as any, + "hi", + ); + + await flush(); + await flush(); + + return result.accepted ? result.messageId : undefined; + }; + + it("retracts them once written if the message was deleted meanwhile", async () => { + audited = true; + + const messageId = await sayInTournament(); + + expect(notifications.notifyPlayers).toHaveBeenCalled(); + expect( + queries.find(({ sql }) => + sql.includes("SELECT 1 FROM public.chat_message_deletions"), + )?.bindings, + ).toEqual([messageId]); + expect(notifications.retractChatMessage).toHaveBeenCalledWith( + messageId, + ); + }); + + it("leaves them alone when nothing deleted the message", async () => { + await sayInTournament(); + + expect(notifications.notifyPlayers).toHaveBeenCalled(); + expect(notifications.retractChatMessage).not.toHaveBeenCalled(); + }); + }); + + it("stamps each chat notification with the message it announces", async () => { + redis.hget.mockResolvedValue(JSON.stringify({ user: { steam_id: ME } })); + role = "user"; + + const result = await service.sendMessageToChat( + ChatLobbyType.Direct, + directRoomId(ME, FRIEND), + { steam_id: ME, name: "Someone", role: "user" } as any, + "hi", + ); + + await flush(); + + expect(result.accepted).toBe(true); + expect(notifications.notifyPlayers).toHaveBeenCalledWith( + "ChatMessage", + expect.objectContaining({ + data: expect.objectContaining({ + messageId: result.accepted ? result.messageId : undefined, + }), + }), + ); + }); + }); + describe("rosters", () => { it("resolves both parties of a conversation", async () => { expect( diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 00ad315e..75d3b01a 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -21,6 +21,7 @@ import { parseDirectRoomId } from "./utilities/directRoomId"; import { ChatErrorCode } from "./enums/ChatErrorCode"; import { ChatMessage, ChatMessageSource } from "./types/ChatMessage"; import { ChatSendResult } from "./types/ChatSendResult"; +import { ChatDeleteResult } from "./types/ChatDeleteResult"; @Injectable() export class ChatService { @@ -574,6 +575,17 @@ export class ChatService { return { accepted: false, code: ChatErrorCode.NotAllowed }; } + // The game server already enforces a gag on what is typed in game, and a + // gag is a sanction on group chat -- a conversation between friends is left + // alone. + if ( + source === "web" && + type !== ChatLobbyType.Direct && + (await this.isGagged(player.steam_id)) + ) { + return { accepted: false, code: ChatErrorCode.Gagged }; + } + const name = await this.redis.get( HasuraService.PLAYER_NAME_CACHE_KEY(player.steam_id), ); @@ -632,6 +644,7 @@ export class ChatService { player, message.from.name, text, + message.id, ).catch((error) => { this.logger.warn(`unable to notify ${type}:${id} of a message`, error); }); @@ -661,6 +674,125 @@ export class ChatService { return await this.canAccessLobby(type, id, user); } + private async isGagged(steamId: string): Promise { + const [row] = await this.postgres.query>( + `SELECT public.is_gagged(p) AS gagged + FROM public.players p + WHERE p.steam_id = $1::bigint`, + [String(steamId)], + ); + + return row?.gagged === true; + } + + private static readonly UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + public async deleteMessage( + type: ChatLobbyType, + id: string, + messageId: string, + user: User, + ): Promise { + if (type === ChatLobbyType.Direct) { + return { deleted: false, code: ChatErrorCode.NotAllowed }; + } + + const current = await this.getCurrentUser(user.steam_id); + + if (!current) { + return { deleted: false, code: ChatErrorCode.NotAllowed }; + } + + const messageKey = `chat_${type}_${id}`; + const raw = ChatService.UUID.test(messageId) + ? await this.redis.hget(messageKey, messageId) + : null; + + if (!raw) { + return { deleted: false, code: ChatErrorCode.NotFound }; + } + + const message = JSON.parse(raw) as ChatMessage; + + if (!(await this.canDelete(message, type, id, current))) { + return { deleted: false, code: ChatErrorCode.NotAllowed }; + } + + // Audited before it is removed, so no failure part way through can take a + // message down without its evidence. A retry finds the row and carries on. + await this.recordDeletion(type, id, messageId, message, current); + + await this.redis.hdel(messageKey, messageId); + + void this.to(type, id, "deleted", { id: messageId }); + + await this.notifications.retractChatMessage(messageId).catch((error) => { + this.logger.warn( + `unable to retract notifications for ${type}:${id} message ${messageId}`, + error, + ); + }); + + return { deleted: true }; + } + + private async canDelete( + message: ChatMessage, + type: ChatLobbyType, + id: string, + user: User, + ): Promise { + if (!isRoleAbove(user.role, "moderator")) { + return false; + } + + return await this.canAccessLobby(type, id, user); + } + + private async recordDeletion( + type: ChatLobbyType, + id: string, + messageId: string, + message: ChatMessage, + deletedBy: User, + ) { + const createdAt = new Date(message.timestamp); + + await this.postgres.query( + `INSERT INTO public.chat_message_deletions + (message_id, room_type, room_id, author_steam_id, message, + message_created_at, source, deleted_by_steam_id) + SELECT $1::uuid, $2, $3, + (SELECT steam_id FROM public.players + WHERE steam_id = $4::bigint), + $5, $6::timestamptz, $7, $8::bigint + ON CONFLICT (room_type, room_id, message_id) DO NOTHING`, + [ + messageId, + type, + id, + ChatService.authorSteamId(message), + String(message.message ?? ""), + Number.isNaN(createdAt.getTime()) ? null : createdAt.toISOString(), + message.source ?? null, + deletedBy.steam_id, + ], + ); + } + + // A steam id stored as a JSON number has already been rounded by JSON.parse + // onto some other account, and would pin the message on the wrong player. + private static authorSteamId(message: ChatMessage): string | null { + const steamId: unknown = message.from?.steam_id; + + if (typeof steamId !== "string" || !/^\d{1,20}$/.test(steamId)) { + return null; + } + + return steamId; + } + // The name and role caches are separate keys with separate lifetimes, so // either can be missing while the other is not. private static cachedOr(cached: string | null, fallback: T): T { @@ -691,6 +823,7 @@ export class ChatService { sender: User, senderName: string, message: string, + messageId: string, ) { const members = await this.getLobbyMemberSteamIds(type, id); const senderSteamId = String(sender.steam_id); @@ -717,6 +850,7 @@ export class ChatService { threadLabel: await this.threadLabel(type, id, sender), icon: sender.avatar_url, senderSteamId, + messageId, }, }); @@ -729,6 +863,25 @@ export class ChatService { entityId, targets, ); + + // A delete that landed while the rows above were being written retracted + // nothing. Its audit row is committed before it retracts, and unlike the + // redis field it is not gone just because the message expired or moved. + if (type !== ChatLobbyType.Direct && (await this.wasDeleted(messageId))) { + await this.notifications.retractChatMessage(messageId); + } + } + + private async wasDeleted(messageId: string): Promise { + const [row] = await this.postgres.query>( + `SELECT EXISTS ( + SELECT 1 FROM public.chat_message_deletions + WHERE message_id = $1::uuid + ) AS deleted`, + [messageId], + ); + + return row?.deleted === true; } // Match chat is its own notification type, and so its own push category. @@ -1427,7 +1580,7 @@ export class ChatService { public async to( type: ChatLobbyType, id: string, - event: "chat" | "list" | "messages" | "joined" | "left", + event: "chat" | "deleted" | "list" | "messages" | "joined" | "left", data: Record, ) { const users = await this.getAllUsersInLobby(type, id); diff --git a/src/chat/enums/ChatErrorCode.ts b/src/chat/enums/ChatErrorCode.ts index 8f3e3b1c..35624e17 100644 --- a/src/chat/enums/ChatErrorCode.ts +++ b/src/chat/enums/ChatErrorCode.ts @@ -4,4 +4,6 @@ export enum ChatErrorCode { TooLong = "too_long", NotAllowed = "not_allowed", Invalid = "invalid", + Gagged = "gagged", + NotFound = "not_found", } diff --git a/src/chat/types/ChatAction.ts b/src/chat/types/ChatAction.ts new file mode 100644 index 00000000..84411874 --- /dev/null +++ b/src/chat/types/ChatAction.ts @@ -0,0 +1,3 @@ +// Echoed on `chat:ack` and `chat:error` so the client knows which request the +// answer is for. A contract with the web -- add to it, never rename. +export type ChatAction = "send" | "delete"; diff --git a/src/chat/types/ChatDeleteResult.ts b/src/chat/types/ChatDeleteResult.ts new file mode 100644 index 00000000..4484c771 --- /dev/null +++ b/src/chat/types/ChatDeleteResult.ts @@ -0,0 +1,5 @@ +import { ChatErrorCode } from "../enums/ChatErrorCode"; + +export type ChatDeleteResult = + | { deleted: true } + | { deleted: false; code: ChatErrorCode }; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index f42a03c4..f54f0466 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -769,6 +769,26 @@ export class NotificationsService { ); } + // By message id alone: a draft lobby's history moves into the match room with + // its ids intact, while its rows keep the draft's type and entity. + // + // Soft-deleting is also what stops a push still waiting in a bundling window, + // since chat delivery skips deleted rows. The text goes too, because a + // recipient can read and restore their own deleted rows. Rows + // collapseOlderUnread already retired stay retired, even when the one that + // superseded them goes. + async retractChatMessage(messageId: string) { + await this.postgres.query( + `UPDATE public.notifications + SET deleted_at = COALESCE(deleted_at, now()), + message = '' + WHERE data->>'messageId' = $1 + AND type IN ('ChatMessage', 'MatchChatMessage') + AND (deleted_at IS NULL OR message <> '')`, + [messageId], + ); + } + // Opening a conversation should clear its badge everywhere, not just in the // tab that was open. async markConversationRead( diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index f809f706..3be5e9bc 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -41,6 +41,7 @@ export type NotificationData = { icon?: string | null; image?: string | null; senderSteamId?: string; + messageId?: string; }; // The bell's buttons, as written by NotificationsService.send / notifyPlayers. diff --git a/test/chat-moderation.spec.ts b/test/chat-moderation.spec.ts new file mode 100644 index 00000000..0af53e13 --- /dev/null +++ b/test/chat-moderation.spec.ts @@ -0,0 +1,642 @@ +import { randomUUID } from "crypto"; +import { readFileSync } from "fs"; +import { join } from "path"; +import * as webPush from "web-push"; +import IORedis, { Redis } from "ioredis"; +import { GenericContainer, StartedTestContainer } from "testcontainers"; +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 { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; +import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; +import { NotificationsService } from "./../src/notifications/notifications.service"; +import { NotificationPreferencesService } from "./../src/notifications/preferences/notification-preferences.service"; +import { PushNotificationsService } from "./../src/notifications/push/push-notifications.service"; + +jest.mock("web-push", () => ({ + setVapidDetails: jest.fn(), + sendNotification: jest.fn().mockResolvedValue({}), + generateVAPIDKeys: jest.fn(), +})); + +// The audit row, the redis removal and the bell retraction each live in a +// different store, and the unit specs stub all three. +describe("chat moderation (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let container: StartedTestContainer; + let redis: Redis; + let chat: ChatService; + let notifications: NotificationsService; + + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + const up = readFileSync( + join( + __dirname, + "../hasura/migrations/default/1888000000100_chat_message_deletions/up.sql", + ), + "utf8", + ); + + 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 }; + } + + // Who gets into which room is chat.service.spec's subject. + if (query.matches_by_pk) { + return { + matches_by_pk: { + is_coach: false, + is_organizer: true, + is_in_lineup: false, + }, + }; + } + + return {}; + }), + }); + + const pushService = () => + new PushNotificationsService( + logger as any, + postgres, + { + get: (key: string) => + key === "app" + ? { webDomain: "https://example.com" } + : { + publicKey: "public-key", + privateKey: "private-key", + subject: "https://example.com", + }, + } as any, + { add: async () => ({}) } as any, + { + getConnection: () => ({ + exists: async () => 0, + set: async () => "OK", + get: async (): Promise => null, + ttl: async () => -2, + del: async () => 1, + rpush: async () => 1, + expire: async () => 1, + multi: () => ({ + lrange() { + return this; + }, + del() { + return this; + }, + rpush() { + return this; + }, + expire() { + return this; + }, + exec: async (): Promise> => [[null, []]], + }), + pipeline: () => { + const queued: string[] = []; + return { + set: () => {}, + hvals: (key: string) => queued.push(key), + exec: async (): Promise> => + queued.map(() => [null, []] as [unknown, Array]), + }; + }, + subscribe: async () => 1, + publish: async () => 1, + on: () => {}, + }), + } as any, + ); + + 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("ChatModerationTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199600000000n); + + notifications = new NotificationsService( + hasura() as any, + postgres, + logger as any, + { get: () => ({ webDomain: "https://example.com" }) } as any, + new NotificationPreferencesService(postgres), + { add: jest.fn() } as any, + { add: jest.fn() } as any, + { add: jest.fn() } as any, + ); + + chat = new ChatService( + logger as any, + {} as any, + hasura() as any, + postgres, + { getConnection: () => redis } as any, + notifications, + ); + }, 600_000); + + afterAll(async () => { + redis?.disconnect(); + await container?.stop(); + await db?.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await redis.flushall(); + await postgres.query("DELETE FROM chat_message_deletions"); + await postgres.query("DELETE FROM player_sanctions"); + await postgres.query("DELETE FROM push_subscriptions"); + await postgres.query("DELETE FROM notifications"); + await postgres.query("DELETE FROM players"); + }); + + const moderator = async () => { + const steamId = await fx.player("Mod"); + await postgres.query( + `UPDATE players SET role = 'moderator' WHERE steam_id = $1::bigint`, + [steamId], + ); + return { steam_id: steamId, name: "Mod", role: "moderator" } as any; + }; + + const post = async ( + matchId: string, + authorSteamId: string, + message = "something awful", + ) => { + const id = randomUUID(); + await redis.hset( + `chat_match_${matchId}`, + id, + JSON.stringify({ + id, + message, + timestamp: "2025-01-01T00:00:00.000Z", + source: "web", + from: { role: "user", name: "Author", steam_id: authorSteamId }, + }), + ); + return id; + }; + + const audits = () => + postgres.query< + Array<{ + message_id: string; + room_type: string; + room_id: string; + author_steam_id: string | null; + message: string; + message_created_at: Date | null; + source: string | null; + deleted_by_steam_id: string | null; + }> + >( + `SELECT message_id::text AS message_id, room_type, room_id, + author_steam_id::text AS author_steam_id, message, + message_created_at, source, + deleted_by_steam_id::text AS deleted_by_steam_id + FROM chat_message_deletions`, + ); + + describe("deleting", () => { + it("keeps the evidence and removes only that message", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + const other = await post(matchId, author, "fine"); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + expect(await redis.hexists(`chat_match_${matchId}`, target)).toBe(0); + expect(await redis.hexists(`chat_match_${matchId}`, other)).toBe(1); + + expect(await audits()).toEqual([ + { + message_id: target, + room_type: "match", + room_id: matchId, + author_steam_id: author, + message: "something awful", + message_created_at: new Date("2025-01-01T00:00:00.000Z"), + source: "web", + deleted_by_steam_id: mod.steam_id, + }, + ]); + }); + + it("still deletes a message whose author has no player row", async () => { + const mod = await moderator(); + const matchId = randomUUID(); + const target = await post(matchId, "76561199699999999"); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + const [audit] = await audits(); + + expect(audit.author_steam_id).toBeNull(); + expect(audit.message).toBe("something awful"); + }); + + it("finishes a delete whose redis removal failed, keeping the first audit", async () => { + const mod = await moderator(); + const earlier = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + + await postgres.query( + `INSERT INTO chat_message_deletions + (message_id, room_type, room_id, author_steam_id, message, + deleted_by_steam_id) + VALUES ($1::uuid, 'match', $2, $3::bigint, 'something awful', + $4::bigint)`, + [target, matchId, author, earlier.steam_id], + ); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + expect(await redis.hexists(`chat_match_${matchId}`, target)).toBe(0); + + const rows = await audits(); + + expect(rows).toHaveLength(1); + expect(rows[0].deleted_by_steam_id).toBe(earlier.steam_id); + }); + + it("answers not_found once the message is gone", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: false, code: ChatErrorCode.NotFound }); + + expect(await audits()).toHaveLength(1); + }); + + it("tells a deleted message from one that merely expired", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const deleted = await post(matchId, author); + const expired = await post(matchId, author, "fine"); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, deleted, mod); + await redis.hdel(`chat_match_${matchId}`, expired); + + expect(await chat["wasDeleted"](deleted)).toBe(true); + expect(await chat["wasDeleted"](expired)).toBe(false); + }); + + it("keeps the audit when the author's player row goes", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const matchId = randomUUID(); + const target = await post(matchId, author); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + await postgres.query(`DELETE FROM players WHERE steam_id = $1::bigint`, [ + author, + ]); + + const [audit] = await audits(); + + expect(audit.author_steam_id).toBeNull(); + expect(audit.message).toBe("something awful"); + }); + }); + + describe("retracting the bell", () => { + const chatNotification = async ( + steamId: string, + entityId: string, + messageId: string, + type = "MatchChatMessage", + ) => { + const [row] = await postgres.query>( + `INSERT INTO notifications + (type, title, message, role, steam_id, entity_id, data) + VALUES ($4, 'Author', 'something awful', 'user', + $1::bigint, $2, + jsonb_build_object('threadKey', 'chat:' || $2, + 'messageId', $3::text)) + RETURNING id::text AS id`, + [steamId, entityId, messageId, type], + ); + return row.id; + }; + + // Match chat is off for push by default, so delivery is shown on a room + // whose category is on. + const subscribedReader = async () => { + const reader = await fx.player("Reader"); + await postgres.query( + `INSERT INTO push_subscriptions (steam_id, endpoint, p256dh, auth) + VALUES ($1::bigint, $2, 'key', 'auth')`, + [reader, `https://fcm.googleapis.com/fcm/send/${reader}`], + ); + return reader; + }; + + const deliver = async (id: string) => { + const push = pushService(); + await push.loadKeys(); + await push.sendForNotification({ id, type: "ChatMessage" }); + }; + + const notification = async (id: string) => + ( + await postgres.query< + Array<{ deleted_at: Date | null; message: string }> + >(`SELECT deleted_at, message FROM notifications WHERE id = $1::uuid`, [ + id, + ]) + ).at(0); + + const deletedAt = async (id: string) => + (await notification(id))?.deleted_at; + + it("retracts the deleted message's row and no other", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const matchId = randomUUID(); + const target = await post(matchId, author); + const other = await post(matchId, author, "fine"); + + const retracted = await chatNotification( + reader, + `match:${matchId}`, + target, + ); + const kept = await chatNotification(reader, `match:${matchId}`, other); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + + expect(await deletedAt(retracted)).toBeInstanceOf(Date); + expect(await deletedAt(kept)).toBeNull(); + }); + + it("takes the text out of the row, so it cannot be read back", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const matchId = randomUUID(); + const target = await post(matchId, author); + const id = await chatNotification(reader, `match:${matchId}`, target); + + await chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod); + + expect((await notification(id))?.message).toBe(""); + }); + + it("blanks a row the bell had already collapsed, leaving it retired", async () => { + const reader = await fx.player("Reader"); + const messageId = randomUUID(); + const id = await chatNotification(reader, "tournament:t-1", messageId); + await postgres.query( + `UPDATE notifications + SET deleted_at = now() - interval '1 hour' + WHERE id = $1::uuid`, + [id], + ); + const before = await deletedAt(id); + + await notifications.retractChatMessage(messageId); + + expect(await notification(id)).toEqual({ + deleted_at: before, + message: "", + }); + }); + + it("retracts a draft lobby's message after it moved into the match", async () => { + const mod = await moderator(); + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const draftId = randomUUID(); + const matchId = randomUUID(); + const target = await post(draftId, author); + await redis.rename(`chat_match_${draftId}`, `chat_draft_${draftId}`); + + const id = await chatNotification( + reader, + `draft:${draftId}`, + target, + "ChatMessage", + ); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + draftId, + ChatLobbyType.Match, + matchId, + ); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, matchId, target, mod), + ).resolves.toEqual({ deleted: true }); + + expect(await deletedAt(id)).toBeInstanceOf(Date); + }); + + it("leaves a message's rows alone when it expired rather than being deleted", async () => { + // A 0 TTL drops the field as soon as it is written, and a draft lobby's + // history moves out from under it into the match. Neither is a delete. + const author = await fx.player("Author"); + const reader = await fx.player("Reader"); + const matchId = randomUUID(); + const messageId = await post(matchId, author); + const id = await chatNotification(reader, `match:${matchId}`, messageId); + await redis.hdel(`chat_match_${matchId}`, messageId); + + const members = jest + .spyOn(chat, "getLobbyMemberSteamIds") + .mockResolvedValueOnce([author, reader]); + const written = jest + .spyOn(notifications, "notifyPlayers") + .mockResolvedValueOnce(undefined); + + await chat["notifyLobbyMembers"]( + ChatLobbyType.Match, + matchId, + { steam_id: author, name: "Author", role: "user" } as any, + "Author", + "something awful", + messageId, + ); + + members.mockRestore(); + written.mockRestore(); + + expect(await notification(id)).toEqual({ + deleted_at: null, + message: "something awful", + }); + }); + + it("leaves a notification that is not chat alone, whatever its data holds", async () => { + const reader = await fx.player("Reader"); + const messageId = randomUUID(); + const id = await chatNotification( + reader, + "tournament:t-1", + messageId, + "MatchStatusChange", + ); + + await notifications.retractChatMessage(messageId); + + expect(await notification(id)).toEqual({ + deleted_at: null, + message: "something awful", + }); + }); + + it("finds the message's rows through an index", async () => { + const plan = await postgres.transaction(async (client) => { + await client.query("SET LOCAL enable_seqscan = off"); + + const { rows } = await client.query( + `EXPLAIN SELECT id FROM notifications + WHERE data->>'messageId' = $1`, + [randomUUID()], + ); + + return rows.map((row) => row["QUERY PLAN"]).join("\n"); + }); + + expect(plan).toContain("notifications_message_id_idx"); + }); + + it("drops a retracted row from push delivery", async () => { + const reader = await subscribedReader(); + const messageId = randomUUID(); + const id = await chatNotification( + reader, + "tournament:t-1", + messageId, + "ChatMessage", + ); + + await notifications.retractChatMessage(messageId); + await deliver(id); + + expect(webPush.sendNotification).not.toHaveBeenCalled(); + }); + + it("still delivers the row next to it", async () => { + const reader = await subscribedReader(); + const retracted = randomUUID(); + await chatNotification( + reader, + "tournament:t-1", + retracted, + "ChatMessage", + ); + const kept = await chatNotification( + reader, + "tournament:t-1", + randomUUID(), + "ChatMessage", + ); + + await notifications.retractChatMessage(retracted); + await deliver(kept); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + }); + }); + + describe("gag", () => { + const say = (steamId: string) => + chat.sendMessageToChat( + ChatLobbyType.Tournament, + "t-1", + { steam_id: steamId, name: "Someone", role: "user" } as any, + "hello", + true, + ); + + const sanction = ( + steamId: string, + type: string, + removeAt: string | null = null, + ) => + postgres.query( + `INSERT INTO player_sanctions + (player_steam_id, type, remove_sanction_date) + VALUES ($1::bigint, $2, $3::timestamptz)`, + [steamId, type, removeAt], + ); + + it.each(["gag", "silence"])("keeps a player under %s out", async (type) => { + const steamId = await fx.player(); + await sanction(steamId, type); + + await expect(say(steamId)).resolves.toEqual({ + accepted: false, + code: ChatErrorCode.Gagged, + }); + }); + + it("lets a player through once the gag has expired", async () => { + const steamId = await fx.player(); + await sanction( + steamId, + "gag", + new Date(Date.now() - 60_000).toISOString(), + ); + + await expect(say(steamId)).resolves.toMatchObject({ accepted: true }); + }); + + it("does not treat a voice mute as a gag", async () => { + const steamId = await fx.player(); + await sanction(steamId, "mute"); + + await expect(say(steamId)).resolves.toMatchObject({ accepted: true }); + }); + }); + + it("re-applies the migration cleanly", async () => { + await expect(postgres.query(up)).resolves.toBeDefined(); + await expect(postgres.query(up)).resolves.toBeDefined(); + }); +});