diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 349129d4843..18eaebc2154 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -28,6 +28,9 @@ function createHarness(params?: { otherUserId: string; updatedAt?: string; completed?: boolean; + pending?: boolean; + phase?: number; + phaseName?: string; sas?: { decimal?: [number, number, number]; emoji?: Array<[string, string]>; @@ -319,6 +322,117 @@ describe("registerMatrixMonitorEvents verification routing", () => { expect(sasBodies).toHaveLength(1); }); + it("ignores cancelled verification flows when DM fallback resolves SAS notices", async () => { + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications: [ + { + id: "verification-old-cancelled", + transactionId: "$old-flow", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + phaseName: "cancelled", + phase: 4, + pending: false, + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + { + id: "verification-new-active", + transactionId: "$different-flow-id", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:43:54.000Z").toISOString(), + phaseName: "started", + phase: 3, + pending: true, + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + }, + ], + }); + + roomEventListener("!dm:example.org", { + event_id: "$start-active", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-active" }, + }, + }); + + 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 decimal: 6158 1986 3513"))).toBe(true); + }); + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + }); + + it("does not emit SAS notices for cancelled verification events", async () => { + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications: [ + { + id: "verification-cancelled", + transactionId: "$req-cancelled", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + phaseName: "cancelled", + phase: 4, + pending: false, + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + ], + }); + + roomEventListener("!dm:example.org", { + event_id: "$cancelled-1", + sender: "@alice:example.org", + type: "m.key.verification.cancel", + origin_server_ts: Date.now(), + content: { + code: "m.mismatched_sas", + reason: "The SAS did not match.", + "m.relates_to": { event_id: "$req-cancelled" }, + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const body = getSentNoticeBody(sendMessage, 0); + expect(body).toContain("Matrix verification cancelled by @alice:example.org"); + expect(body).not.toContain("SAS decimal:"); + }); + it("warns once when encrypted events arrive without Matrix encryption enabled", () => { const { logger, roomEventListener } = createHarness({ authEncryption: false, diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts index fe6588773cc..672c37c57d6 100644 --- a/extensions/matrix/src/matrix/monitor/verification-events.ts +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -18,6 +18,9 @@ type MatrixVerificationSummaryLike = { otherUserId: string; updatedAt?: string; completed?: boolean; + pending?: boolean; + phase?: number; + phaseName?: string; sas?: { decimal?: [number, number, number]; emoji?: Array<[string, string]>; @@ -162,6 +165,19 @@ function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number { return Number.isFinite(ts) ? ts : 0; } +function isActiveVerificationSummary(summary: MatrixVerificationSummaryLike): boolean { + if (summary.completed === true) { + return false; + } + if (summary.phaseName === "cancelled" || summary.phaseName === "done") { + return false; + } + if (typeof summary.phase === "number" && summary.phase >= 4) { + return false; + } + return true; +} + async function resolveVerificationSummaryForSignal( client: MatrixClient, params: { @@ -203,10 +219,10 @@ async function resolveVerificationSummaryForSignal( } // Fallback for DM flows where transaction IDs do not match room event IDs consistently. - const byUser = list - .filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true) - .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0]; - return byUser ?? null; + const activeByUser = list + .filter((entry) => entry.otherUserId === params.senderId && isActiveVerificationSummary(entry)) + .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a)); + return activeByUser.length === 1 ? (activeByUser[0] ?? null) : null; } function trackBounded(set: Set, value: string): boolean { @@ -288,7 +304,10 @@ export function createMatrixVerificationEventRouter(params: { senderId, flowId, }).catch(() => null); - const sasNotice = summary ? formatVerificationSasNotice(summary) : null; + const sasNotice = + summary && isActiveVerificationSummary(summary) + ? formatVerificationSasNotice(summary) + : null; const notices: string[] = []; if (stageNotice) {