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 type { MatrixVerificationSummary } from "../sdk/verification-manager.js"; import { registerMatrixMonitorEvents } from "./events.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js";
type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void;
type FailedDecryptListener = (roomId: string, event: MatrixRawEvent, error: Error) => Promise<void>;
type VerificationSummaryListener = (summary: MatrixVerificationSummary) => void;
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): string { const calls = sendMessage.mock.calls as unknown[][]; return getSentNoticeBodyFromCall(calls[index] ?? []);
}
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined; if (!roomEventListener) { thrownew Error("room.event listener was not registered");
}
return {
onRoomMessage,
sendMessage,
invalidateRoom,
rememberInvite,
roomEventListener,
listVerifications,
readStoreAllowFrom,
logger,
formatNativeDependencyHint,
logVerboseMessage,
flushTasks,
runDetachedTask,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
failedDecryptListener: listeners.get("room.failed_decryption") as
| FailedDecryptListener
| undefined,
verificationSummaryListener: listeners.get("verification.summary") as
| VerificationSummaryListener
| undefined,
roomInviteListener: listeners.get("room.invite") as RoomEventListener | undefined,
roomJoinListener: listeners.get("room.join") as RoomEventListener | undefined,
};
}
it("remembers invite provenance even when Matrix omits the direct invite hint", async () => { const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness(); if (!roomInviteListener) { thrownew Error("room.invite listener was not registered");
}
it("posts verification request notices directly into the room", async () => { const { onRoomMessage, sendMessage, roomMessageListener, flushTasks } = createHarness(); if (!roomMessageListener) { thrownew 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 flushTasks();
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("blocks verification request notices when dmPolicy pairing would block the sender", async () => { const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage, flushTasks } =
createHarness({
dmPolicy: "pairing",
}); if (!roomMessageListener) { thrownew Error("room.message listener was not registered");
}
it("does not consult the allow store when dmPolicy is open", async () => { const { sendMessage, roomMessageListener, readStoreAllowFrom, flushTasks } = createHarness({
dmPolicy: "open",
}); if (!roomMessageListener) { thrownew Error("room.message listener was not registered");
}
await flushTasks();
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 notices from summary updates using the active strict DM when room mapping is missing", async () => { const { sendMessage, verificationSummaryListener, flushTasks } = createHarness({
joinedMembersByRoom: { "!dm-active:example.org": ["@alice:example.org", "@bot:example.org"],
},
}); if (!verificationSummaryListener) { thrownew Error("verification.summary listener was not registered");
}
await flushTasks();
expect(sendMessage).toHaveBeenCalledTimes(1); const roomId = ((sendMessage.mock.calls as unknown[][])[0]?.[0] ?? "") as string; const body = getSentNoticeBody(sendMessage, 0);
expect(roomId).toBe("!dm-active:example.org");
expect(body).toContain("SAS decimal: 4321 8765 2109");
});
it("prefers the canonical active DM over the most recent verification room for unmapped SAS summaries", async () => { const { sendMessage, roomEventListener, verificationSummaryListener, flushTasks } =
createHarness({
joinedMembersByRoom: { "!dm-active:example.org": ["@alice:example.org", "@bot:example.org"], "!dm-current:example.org": ["@alice:example.org", "@bot:example.org"],
},
}); if (!verificationSummaryListener) { thrownew Error("verification.summary listener was not registered");
}
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" },
);
});
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,
});
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" },
);
});
it("adds self-device guidance when decrypt failures come from the same Matrix user", async () => { const { logger, failedDecryptListener } = createHarness({
accountId: "ops",
selfUserId: "@gumadeiras:matrix.example.org",
}); if (!failedDecryptListener) { thrownew Error("room.failed_decryption listener was not registered");
}
await failedDecryptListener( "!room:example.org",
{
event_id: "$enc-self",
sender: "@gumadeiras:matrix.example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
expect(logger.warn).toHaveBeenNthCalledWith( 1, "Failed to decrypt message",
expect.objectContaining({
roomId: "!room:example.org",
eventId: "$enc-self",
sender: "@gumadeiras:matrix.example.org",
senderMatchesOwnUser: true,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 2, "matrix: failed to decrypt a message from this same Matrix user. This usually means another Matrix device did not share the room key, or another OpenClaw runtime is using the same account. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.",
{
roomId: "!room:example.org",
eventId: "$enc-self",
sender: "@gumadeiras:matrix.example.org",
},
);
});
it("does not add self-device guidance for decrypt failures from another sender", async () => { const { logger, failedDecryptListener } = createHarness({
accountId: "ops",
selfUserId: "@gumadeiras:matrix.example.org",
}); if (!failedDecryptListener) { thrownew Error("room.failed_decryption listener was not registered");
}
await failedDecryptListener( "!room:example.org",
{
event_id: "$enc-other",
sender: "@alice:matrix.example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
for (const [index, roomId] of [ "!room-a:example.org", "!room-b:example.org", "!room-c:example.org",
].entries()) {
await failedDecryptListener(
roomId,
{
event_id: `$enc-fresh-${index + 1}`,
sender: `@alice${index + 1}:matrix.example.org`,
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now() - 1_000 * (index + 1),
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
}
expect(logger.warn).toHaveBeenNthCalledWith( 1, "Failed to decrypt fresh post-healthy-sync message",
expect.objectContaining({
eventId: "$enc-fresh-1",
freshAfterHealthySync: true,
postHealthySyncFailureCount: 1,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 2, "Failed to decrypt fresh post-healthy-sync message",
expect.objectContaining({
eventId: "$enc-fresh-2",
freshAfterHealthySync: true,
postHealthySyncFailureCount: 2,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 3, "Failed to decrypt fresh post-healthy-sync message",
expect.objectContaining({
eventId: "$enc-fresh-3",
freshAfterHealthySync: true,
postHealthySyncFailureCount: 3,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 4, "matrix: repeated fresh encrypted messages are still failing to decrypt after Matrix resumed healthy sync. This device may still be missing new room keys. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.",
expect.objectContaining({
failureCount: 3,
roomCount: 3,
senderCount: 3,
rooms: ["!room-a:example.org", "!room-b:example.org", "!room-c:example.org"],
sampleEventIds: ["$enc-fresh-1", "$enc-fresh-2", "$enc-fresh-3"],
}),
);
} finally {
vi.useRealTimers();
}
});
it("keeps decrypt failures before healthy sync on the generic warning path", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-10T16:21:00.000Z")); try {
let healthySyncSinceMs: number | undefined; const { logger, failedDecryptListener } = createHarness({
accountId: "ops",
getHealthySyncSinceMs: () => healthySyncSinceMs,
}); if (!failedDecryptListener) { thrownew Error("room.failed_decryption listener was not registered");
}
await failedDecryptListener( "!room:example.org",
{
event_id: "$enc-old",
sender: "@alice:matrix.example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now() - 5 * 60_000,
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
await failedDecryptListener( "!room:example.org",
{
event_id: "$enc-fresh-after-ready",
sender: "@alice:matrix.example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now() + 1,
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
it("re-emits the aggregate warning for a new failure wave after the window clears", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-10T16:21:00.000Z")); try { const healthySyncSinceMs = Date.now() - 60_000; const { logger, failedDecryptListener } = createHarness({
accountId: "ops",
getHealthySyncSinceMs: () => healthySyncSinceMs,
}); if (!failedDecryptListener) { thrownew Error("room.failed_decryption listener was not registered");
}
for (const wave of [1, 2]) { for (const index of [1, 2, 3]) {
await failedDecryptListener(
`!room-${wave}-${index}:example.org`,
{
event_id: `$enc-wave-${wave}-${index}`,
sender: `@alice${wave}${index}:matrix.example.org`,
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now() - index * 1_000,
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
}
expect(logger.warn).toHaveBeenNthCalledWith( 4, "matrix: repeated fresh encrypted messages are still failing to decrypt after Matrix resumed healthy sync. This device may still be missing new room keys. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.",
expect.objectContaining({
sampleEventIds: ["$enc-wave-1-1", "$enc-wave-1-2", "$enc-wave-1-3"],
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 8, "matrix: repeated fresh encrypted messages are still failing to decrypt after Matrix resumed healthy sync. This device may still be missing new room keys. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.",
expect.objectContaining({
sampleEventIds: ["$enc-wave-2-1", "$enc-wave-2-2", "$enc-wave-2-3"],
}),
);
} finally {
vi.useRealTimers();
}
});
it("resets tracked failures when healthy sync restarts before the old window expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-10T16:21:00.000Z")); try {
let healthySyncSinceMs = Date.now() - 60_000; const { logger, failedDecryptListener } = createHarness({
accountId: "ops",
getHealthySyncSinceMs: () => healthySyncSinceMs,
}); if (!failedDecryptListener) { thrownew Error("room.failed_decryption listener was not registered");
}
for (const index of [1, 2, 3]) {
await failedDecryptListener(
`!room-first-${index}:example.org`,
{
event_id: `$enc-first-${index}`,
sender: `@alice-first-${index}:matrix.example.org`,
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now() - index * 1_000,
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
}
healthySyncSinceMs = Date.now();
for (const index of [1, 2, 3]) {
await failedDecryptListener(
`!room-second-${index}:example.org`,
{
event_id: `$enc-second-${index}`,
sender: `@alice-second-${index}:matrix.example.org`,
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now() + index,
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
);
}
expect(logger.warn).toHaveBeenNthCalledWith( 5, "Failed to decrypt fresh post-healthy-sync message",
expect.objectContaining({
eventId: "$enc-second-1",
freshAfterHealthySync: true,
postHealthySyncFailureCount: 1,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 6, "Failed to decrypt fresh post-healthy-sync message",
expect.objectContaining({
eventId: "$enc-second-2",
freshAfterHealthySync: true,
postHealthySyncFailureCount: 2,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 7, "Failed to decrypt fresh post-healthy-sync message",
expect.objectContaining({
eventId: "$enc-second-3",
freshAfterHealthySync: true,
postHealthySyncFailureCount: 3,
}),
);
expect(logger.warn).toHaveBeenNthCalledWith( 8, "matrix: repeated fresh encrypted messages are still failing to decrypt after Matrix resumed healthy sync. This device may still be missing new room keys. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.",
expect.objectContaining({
sampleEventIds: ["$enc-second-1", "$enc-second-2", "$enc-second-3"],
}),
);
} finally {
vi.useRealTimers();
}
});
it("does not throw when getUserId fails during decrypt guidance lookup", async () => { const { logger, logVerboseMessage, failedDecryptListener } = createHarness({
accountId: "ops",
selfUserIdError: new Error("lookup failed"),
}); if (!failedDecryptListener) { thrownew Error("room.failed_decryption listener was not registered");
}
await expect(
failedDecryptListener( "!room:example.org",
{
event_id: "$enc-lookup-fail",
sender: "@gumadeiras:matrix.example.org",
type: EventType.RoomMessageEncrypted,
origin_server_ts: Date.now(),
content: {},
}, new Error("The sender's device has not sent us the keys for this message."),
),
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith( "Failed to decrypt message",
expect.objectContaining({
roomId: "!room:example.org",
eventId: "$enc-lookup-fail",
senderMatchesOwnUser: false,
}),
);
expect(logVerboseMessage).toHaveBeenCalledWith( "matrix: failed resolving self user id for decrypt warning: Error: lookup failed",
);
});
});
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.