From 8de804d0c059e66cf30ad8857e329d05d23d2a0e Mon Sep 17 00:00:00 2001 From: Hiroshi Shinaoka Date: Fri, 6 Feb 2026 01:19:02 +0900 Subject: [PATCH] fix: Remove state_key: null from Seshat search results (#31524) * fix: Remove state_key: null from Seshat search results Seshat includes "state_key": null for non-state events, which causes matrix-js-sdk to incorrectly treat them as state events. This prevents encrypted messages from rendering properly in search results. This fix removes the null state_key from search results and context events before passing them to the SDK. * test: cover local search null state_key edge cases * test: satisfy strict types in searching coverage test --------- Co-authored-by: David Baker --- src/Searching.ts | 20 +++ test/unit-tests/Searching-test.ts | 262 ++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 test/unit-tests/Searching-test.ts diff --git a/src/Searching.ts b/src/Searching.ts index d507bd10ef..28f67522ad 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -175,6 +175,26 @@ async function localSearch( throw new Error("Local search failed"); } + // Fix state_key: null issue - Seshat includes "state_key": null for non-state events, + // which causes matrix-js-sdk to incorrectly treat them as state events + if (localResult.results) { + for (const searchResult of localResult.results) { + const event = searchResult.result as unknown as Record; + if (event?.state_key === null) delete event.state_key; + // Also fix context events + if (searchResult.context) { + for (const ctxEvent of searchResult.context.events_before || []) { + const ev = ctxEvent as unknown as Record; + if (ev?.state_key === null) delete ev.state_key; + } + for (const ctxEvent of searchResult.context.events_after || []) { + const ev = ctxEvent as unknown as Record; + if (ev?.state_key === null) delete ev.state_key; + } + } + } + } + searchArgs.next_batch = localResult.next_batch; const result = { diff --git a/test/unit-tests/Searching-test.ts b/test/unit-tests/Searching-test.ts new file mode 100644 index 0000000000..60b34fb345 --- /dev/null +++ b/test/unit-tests/Searching-test.ts @@ -0,0 +1,262 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type IResultRoomEvents } from "matrix-js-sdk/src/matrix"; + +import eventSearch from "../../src/Searching"; +import EventIndexPeg from "../../src/indexing/EventIndexPeg"; +import { createTestClient } from "../test-utils"; + +describe("Searching", () => { + const mockClient = createTestClient(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("localSearch", () => { + it("removes state_key: null from search results", async () => { + // Mock search results from Seshat that include state_key: null + const mockSearchResults: IResultRoomEvents = { + count: 2, + results: [ + { + rank: 1, + result: { + event_id: "$event1", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567890, + content: { body: "test message 1", msgtype: "m.text" }, + // Seshat incorrectly includes state_key: null for non-state events + state_key: null, + } as any, + context: { + events_before: [ + { + event_id: "$before1", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567889, + content: { body: "before message", msgtype: "m.text" }, + state_key: null, + } as any, + ], + events_after: [ + { + event_id: "$after1", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567891, + content: { body: "after message", msgtype: "m.text" }, + state_key: null, + } as any, + ], + profile_info: {}, + }, + }, + { + rank: 2, + result: { + event_id: "$event2", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567880, + content: { body: "test message 2", msgtype: "m.text" }, + state_key: null, + } as any, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }, + ], + highlights: ["test"], + }; + + // Mock EventIndex.search to return results with state_key: null + const mockEventIndex = { + search: jest.fn().mockResolvedValue(mockSearchResults), + }; + jest.spyOn(EventIndexPeg, "get").mockReturnValue(mockEventIndex as any); + + // Mock crypto to indicate room is encrypted + jest.spyOn(mockClient, "getCrypto").mockReturnValue({ + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true), + } as any); + + // Perform search in an encrypted room + const roomId = "!room:example.org"; + await eventSearch(mockClient, "test", roomId); + + // Verify that state_key: null was removed from the search arguments passed to search + expect(mockEventIndex.search).toHaveBeenCalled(); + + // Get the mock search results that were passed to processRoomEventsSearch + // The state_key should have been deleted from the original results object + const mainEventResult = mockSearchResults.results![0].result as unknown as Record; + expect(mainEventResult.state_key).toBeUndefined(); + + const beforeEvent = mockSearchResults.results![0].context!.events_before![0] as unknown as Record< + string, + unknown + >; + expect(beforeEvent.state_key).toBeUndefined(); + + const afterEvent = mockSearchResults.results![0].context!.events_after![0] as unknown as Record< + string, + unknown + >; + expect(afterEvent.state_key).toBeUndefined(); + + const secondResult = mockSearchResults.results![1].result as unknown as Record; + expect(secondResult.state_key).toBeUndefined(); + }); + + it("does not modify events without state_key: null", async () => { + const mockSearchResults: IResultRoomEvents = { + count: 1, + results: [ + { + rank: 1, + result: { + event_id: "$event1", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567890, + content: { body: "test message", msgtype: "m.text" }, + // No state_key property at all (correct behavior) + } as any, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }, + ], + highlights: ["test"], + }; + + const mockEventIndex = { + search: jest.fn().mockResolvedValue(mockSearchResults), + }; + jest.spyOn(EventIndexPeg, "get").mockReturnValue(mockEventIndex as any); + + jest.spyOn(mockClient, "getCrypto").mockReturnValue({ + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true), + } as any); + + const roomId = "!room:example.org"; + await eventSearch(mockClient, "test", roomId); + + // Verify state_key is still undefined (not accidentally set to something) + const eventResult = mockSearchResults.results![0].result as unknown as Record; + expect("state_key" in eventResult).toBe(false); + }); + + it("handles missing context fields and empty result sets", async () => { + const mockSearchResults: IResultRoomEvents = { + count: 3, + results: [ + { + rank: 1, + result: { + event_id: "$event1", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567890, + content: { body: "test message", msgtype: "m.text" }, + state_key: null, + } as any, + context: { + events_before: [{ event_id: "$before1", state_key: "not-null" } as any], + events_after: [{ event_id: "$after1", state_key: "not-null" } as any], + profile_info: {}, + }, + }, + { + rank: 2, + result: { + event_id: "$event2", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567891, + content: { body: "test message 2", msgtype: "m.text" }, + state_key: null, + } as any, + context: { + profile_info: {}, + } as any, + }, + { + rank: 3, + result: { + event_id: "$event3", + room_id: "!room:example.org", + sender: "@user:example.org", + type: "m.room.message", + origin_server_ts: 1234567892, + content: { body: "test message 3", msgtype: "m.text" }, + state_key: null, + } as any, + context: undefined as any, + }, + ], + highlights: ["test"], + }; + + const mockEventIndex = { + search: jest + .fn() + .mockResolvedValueOnce(mockSearchResults) + .mockResolvedValueOnce({ count: 0, highlights: ["test"] } as IResultRoomEvents), + }; + jest.spyOn(EventIndexPeg, "get").mockReturnValue(mockEventIndex as any); + + jest.spyOn(mockClient, "getCrypto").mockReturnValue({ + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true), + } as any); + + const roomId = "!room:example.org"; + await eventSearch(mockClient, "test", roomId); + await eventSearch(mockClient, "test", roomId); + + const firstMainEvent = mockSearchResults.results![0].result as unknown as Record; + expect(firstMainEvent.state_key).toBeUndefined(); + + const beforeEvent = mockSearchResults.results![0].context!.events_before![0] as unknown as Record< + string, + unknown + >; + expect(beforeEvent.state_key).toBe("not-null"); + + const afterEvent = mockSearchResults.results![0].context!.events_after![0] as unknown as Record< + string, + unknown + >; + expect(afterEvent.state_key).toBe("not-null"); + + const secondMainEvent = mockSearchResults.results![1].result as unknown as Record; + expect(secondMainEvent.state_key).toBeUndefined(); + + const thirdMainEvent = mockSearchResults.results![2].result as unknown as Record; + expect(thirdMainEvent.state_key).toBeUndefined(); + }); + }); +});