Files
openclaw/extensions/matrix/src/matrix/monitor/events.test.ts
2026-03-12 04:36:03 +00:00

388 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import { registerMatrixMonitorEvents } from "./events.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void;
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): string {
const calls = sendMessage.mock.calls as unknown[][];
const payload = (calls[index]?.[1] ?? {}) as { body?: string };
return payload.body ?? "";
}
function createHarness(params?: {
cfg?: CoreConfig;
accountId?: string;
authEncryption?: boolean;
cryptoAvailable?: boolean;
selfUserId?: string;
joinedMembersByRoom?: Record<string, string[]>;
verifications?: Array<{
id: string;
transactionId?: string;
otherUserId: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
}>;
}) {
const listeners = new Map<string, (...args: unknown[]) => void>();
const onRoomMessage = vi.fn(async () => {});
const listVerifications = vi.fn(async () => params?.verifications ?? []);
const sendMessage = vi.fn(async () => "$notice");
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const formatNativeDependencyHint = vi.fn(() => "install hint");
const client = {
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
return client;
}),
sendMessage,
getUserId: vi.fn(async () => params?.selfUserId ?? "@bot:example.org"),
getJoinedRoomMembers: vi.fn(
async (roomId: string) => params?.joinedMembersByRoom?.[roomId] ?? [],
),
...(params?.cryptoAvailable === false
? {}
: {
crypto: {
listVerifications,
},
}),
} as unknown as MatrixClient;
registerMatrixMonitorEvents({
cfg: params?.cfg ?? { channels: { matrix: {} } },
client,
auth: {
accountId: params?.accountId ?? "default",
encryption: params?.authEncryption ?? true,
} as MatrixAuth,
logVerboseMessage: vi.fn(),
warnedEncryptedRooms: new Set<string>(),
warnedCryptoMissingRooms: new Set<string>(),
logger,
formatNativeDependencyHint,
onRoomMessage,
});
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined;
if (!roomEventListener) {
throw new Error("room.event listener was not registered");
}
return {
onRoomMessage,
sendMessage,
roomEventListener,
listVerifications,
logger,
formatNativeDependencyHint,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
};
}
describe("registerMatrixMonitorEvents verification routing", () => {
it("forwards reaction room events into the shared room handler", async () => {
const { onRoomMessage, sendMessage, roomEventListener } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$reaction1",
sender: "@alice:example.org",
type: EventType.Reaction,
origin_server_ts: Date.now(),
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg1",
key: "👍",
},
},
});
await vi.waitFor(() => {
expect(onRoomMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }),
);
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("posts verification request notices directly into the room", async () => {
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
if (!roomMessageListener) {
throw new Error("room.message listener was not registered");
}
roomMessageListener("!room:example.org", {
event_id: "$req1",
sender: "@alice:example.org",
type: EventType.RoomMessage,
origin_server_ts: Date.now(),
content: {
msgtype: "m.key.verification.request",
body: "verification request",
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
expect(onRoomMessage).not.toHaveBeenCalled();
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification request received from @alice:example.org.");
expect(body).toContain('Open "Verify by emoji"');
});
it("posts ready-stage guidance for emoji verification", async () => {
const { sendMessage, roomEventListener } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$ready-1",
sender: "@alice:example.org",
type: "m.key.verification.ready",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req-ready-1" },
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification is ready with @alice:example.org.");
expect(body).toContain('Choose "Verify by emoji"');
});
it("posts SAS emoji/decimal details when verification summaries expose them", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
joinedMembersByRoom: {
"!dm:example.org": ["@alice:example.org", "@bot:example.org"],
},
verifications: [
{
id: "verification-1",
transactionId: "$different-flow-id",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
otherUserId: "@alice:example.org",
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["🎁", "Gift"],
["🌍", "Globe"],
["🐴", "Horse"],
],
},
},
],
});
roomEventListener("!dm:example.org", {
event_id: "$start2",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req2" },
},
});
await vi.waitFor(() => {
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? ""),
);
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
});
it("does not leak SAS details into unrelated non-DM rooms when flow ids do not match", async () => {
const { sendMessage, roomEventListener } = createHarness({
joinedMembersByRoom: {
"!group:example.org": ["@alice:example.org", "@bot:example.org", "@ops:example.org"],
},
verifications: [
{
id: "verification-2",
transactionId: "$different-flow-id",
otherUserId: "@alice:example.org",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["🎁", "Gift"],
["🌍", "Globe"],
["🐴", "Horse"],
],
},
},
],
});
roomEventListener("!group:example.org", {
event_id: "$start-group",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req-group" },
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
expect(getSentNoticeBody(sendMessage, 0)).toContain(
"Matrix verification started with @alice:example.org.",
);
expect(
(sendMessage.mock.calls as unknown[][]).some((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? "").includes("SAS emoji:"),
),
).toBe(false);
});
it("does not emit duplicate SAS notices for the same verification payload", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-3",
transactionId: "$req3",
otherUserId: "@alice:example.org",
sas: {
decimal: [1111, 2222, 3333],
emoji: [
["🚀", "Rocket"],
["🦋", "Butterfly"],
["📕", "Book"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start3",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(sendMessage.mock.calls.length).toBeGreaterThan(0);
});
roomEventListener("!room:example.org", {
event_id: "$key3",
sender: "@alice:example.org",
type: "m.key.verification.key",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(listVerifications).toHaveBeenCalledTimes(2);
});
const sasBodies = sendMessage.mock.calls
.map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? ""))
.filter((body) => body.includes("SAS emoji:"));
expect(sasBodies).toHaveLength(1);
});
it("warns once when encrypted events arrive without Matrix encryption enabled", () => {
const { logger, roomEventListener } = createHarness({
authEncryption: false,
});
roomEventListener("!room:example.org", {
event_id: "$enc1",
sender: "@alice:example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
});
roomEventListener("!room:example.org", {
event_id: "$enc2",
sender: "@alice:example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt",
{ roomId: "!room:example.org" },
);
});
it("uses the active Matrix account path in encrypted-event warnings", () => {
const { logger, roomEventListener } = createHarness({
accountId: "ops",
authEncryption: false,
cfg: {
channels: {
matrix: {
accounts: {
ops: {},
},
},
},
},
});
roomEventListener("!room:example.org", {
event_id: "$enc1",
sender: "@alice:example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
});
expect(logger.warn).toHaveBeenCalledWith(
"matrix: encrypted event received without encryption enabled; set channels.matrix.accounts.ops.encryption=true and verify the device to decrypt",
{ roomId: "!room:example.org" },
);
});
it("warns once when crypto bindings are unavailable for encrypted rooms", () => {
const { formatNativeDependencyHint, logger, roomEventListener } = createHarness({
authEncryption: true,
cryptoAvailable: false,
});
roomEventListener("!room:example.org", {
event_id: "$enc1",
sender: "@alice:example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
});
roomEventListener("!room:example.org", {
event_id: "$enc2",
sender: "@alice:example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
});
expect(formatNativeDependencyHint).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
"matrix: encryption enabled but crypto is unavailable; install hint",
{ roomId: "!room:example.org" },
);
});
});