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 <dbkr@users.noreply.github.com>
This commit is contained in:
Hiroshi Shinaoka 2026-02-06 01:19:02 +09:00 committed by GitHub
parent 1d7fd0b9d3
commit 8de804d0c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 282 additions and 0 deletions

View File

@ -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<string, unknown>;
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<string, unknown>;
if (ev?.state_key === null) delete ev.state_key;
}
for (const ctxEvent of searchResult.context.events_after || []) {
const ev = ctxEvent as unknown as Record<string, unknown>;
if (ev?.state_key === null) delete ev.state_key;
}
}
}
}
searchArgs.next_batch = localResult.next_batch;
const result = {

View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(secondMainEvent.state_key).toBeUndefined();
const thirdMainEvent = mockSearchResults.results![2].result as unknown as Record<string, unknown>;
expect(thirdMainEvent.state_key).toBeUndefined();
});
});
});