2026-07-02 14:30:33 -04:00
|
|
|
import { test } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
2026-10-17 23:40:58 -04:00
|
|
|
import { buildForwardContent, forwardEncryptionWarning, getForwardedMeta } from './forwardContent';
|
2026-07-02 14:30:33 -04:00
|
|
|
|
|
|
|
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
|
|
|
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
|
|
|
|
// MatrixClient / MatrixEvent are mocked minimally. getEditedEvent reads edits off
|
|
|
|
|
// `timelineSet.relations.getChildEventsForEvent(...).getRelations()`, so the base
|
|
|
|
|
// client returns no child edits and the edit test injects one.
|
|
|
|
|
|
|
|
|
|
const SENDER = '@me:example.org';
|
|
|
|
|
|
|
|
|
|
type EventOptions = {
|
|
|
|
|
content?: Record<string, unknown>;
|
|
|
|
|
type?: string;
|
|
|
|
|
id?: string;
|
|
|
|
|
roomId?: string;
|
|
|
|
|
decryptionFailure?: boolean;
|
|
|
|
|
ts?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const makeEvent = (options: EventOptions = {}): MatrixEvent => {
|
|
|
|
|
const {
|
|
|
|
|
content = {},
|
|
|
|
|
type = 'm.room.message',
|
|
|
|
|
id = '$evt:example.org',
|
|
|
|
|
roomId = '!room:example.org',
|
|
|
|
|
decryptionFailure = false,
|
|
|
|
|
ts = 0,
|
|
|
|
|
} = options;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
getContent: () => content,
|
|
|
|
|
getType: () => type,
|
|
|
|
|
getId: () => id,
|
|
|
|
|
getRoomId: () => roomId,
|
|
|
|
|
getSender: () => SENDER,
|
|
|
|
|
getTs: () => ts,
|
|
|
|
|
isDecryptionFailure: () => decryptionFailure,
|
|
|
|
|
} as unknown as MatrixEvent;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Base client: the timeline reports no `m.replace` edits, so the original content
|
|
|
|
|
// is forwarded unchanged.
|
|
|
|
|
const makeClient = (): MatrixClient =>
|
|
|
|
|
({
|
|
|
|
|
getRoom: () => ({
|
|
|
|
|
getUnfilteredTimelineSet: () => ({
|
|
|
|
|
relations: {
|
|
|
|
|
getChildEventsForEvent: () => null,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
}) as unknown as MatrixClient;
|
|
|
|
|
|
|
|
|
|
test('plain text forwards the body and strips m.relates_to', () => {
|
|
|
|
|
const mx = makeClient();
|
|
|
|
|
const mEvent = makeEvent({
|
|
|
|
|
content: {
|
|
|
|
|
msgtype: 'm.text',
|
|
|
|
|
body: 'hello world',
|
|
|
|
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
|
|
|
|
|
|
assert.ok(content);
|
|
|
|
|
assert.equal(content.body, 'hello world');
|
|
|
|
|
assert.equal(content.msgtype, 'm.text');
|
|
|
|
|
assert.equal(content['m.relates_to'], undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('reply quote is stripped from body and formatted_body', () => {
|
|
|
|
|
const mx = makeClient();
|
|
|
|
|
const mEvent = makeEvent({
|
|
|
|
|
content: {
|
|
|
|
|
msgtype: 'm.text',
|
|
|
|
|
body: '> <@alice:example.org> original\n\nmy reply',
|
|
|
|
|
format: 'org.matrix.custom.html',
|
|
|
|
|
formatted_body: '<mx-reply><blockquote>original</blockquote></mx-reply>my reply',
|
|
|
|
|
'm.relates_to': { 'm.in_reply_to': { event_id: '$root:example.org' } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
|
|
|
|
|
|
assert.ok(content);
|
|
|
|
|
assert.equal(content.body, 'my reply');
|
|
|
|
|
assert.equal(content.formatted_body, 'my reply');
|
|
|
|
|
assert.equal(content['m.relates_to'], undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('decryption failure returns undefined', () => {
|
|
|
|
|
const mx = makeClient();
|
|
|
|
|
const mEvent = makeEvent({
|
|
|
|
|
content: { msgtype: 'm.bad.encrypted' },
|
|
|
|
|
decryptionFailure: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.equal(buildForwardContent(mx, mEvent), undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('edited message forwards m.new_content', () => {
|
|
|
|
|
const mEvent = makeEvent({
|
|
|
|
|
content: {
|
|
|
|
|
msgtype: 'm.text',
|
|
|
|
|
body: 'original body',
|
|
|
|
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The latest `m.replace` edit carries the new content under `m.new_content`.
|
|
|
|
|
const editEvent = makeEvent({
|
|
|
|
|
content: { 'm.new_content': { msgtype: 'm.text', body: 'edited body' } },
|
|
|
|
|
ts: 100,
|
|
|
|
|
});
|
|
|
|
|
const mx = {
|
|
|
|
|
getRoom: () => ({
|
|
|
|
|
getUnfilteredTimelineSet: () => ({
|
|
|
|
|
relations: {
|
|
|
|
|
getChildEventsForEvent: () => ({
|
|
|
|
|
getRelations: () => [editEvent],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as MatrixClient;
|
|
|
|
|
|
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
|
|
|
|
|
|
assert.ok(content);
|
|
|
|
|
assert.equal(content.body, 'edited body');
|
|
|
|
|
assert.equal(content.msgtype, 'm.text');
|
|
|
|
|
assert.equal(content['m.new_content'], undefined);
|
|
|
|
|
assert.equal(content['m.relates_to'], undefined);
|
|
|
|
|
});
|
2026-09-18 21:07:33 -04:00
|
|
|
|
|
|
|
|
test('forward stamps io.lotus.forwarded with the original sender / time / room / event', () => {
|
|
|
|
|
const mx = makeClient();
|
|
|
|
|
const mEvent = makeEvent({ content: { msgtype: 'm.text', body: 'prov' } });
|
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
|
assert.ok(content);
|
|
|
|
|
const meta = getForwardedMeta(content);
|
|
|
|
|
assert.ok(meta);
|
|
|
|
|
assert.equal(meta.sender, mEvent.getSender());
|
|
|
|
|
assert.equal(meta.origin_server_ts, mEvent.getTs());
|
|
|
|
|
assert.equal(meta.room_id, mEvent.getRoomId());
|
|
|
|
|
assert.equal(meta.event_id, mEvent.getId());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('forwarding a forward keeps the ORIGINAL provenance', () => {
|
|
|
|
|
const mx = makeClient();
|
|
|
|
|
const original = {
|
|
|
|
|
sender: '@origin:example.org',
|
|
|
|
|
origin_server_ts: 1000,
|
|
|
|
|
room_id: '!origin:example.org',
|
|
|
|
|
event_id: '$origin:example.org',
|
|
|
|
|
};
|
|
|
|
|
const mEvent = makeEvent({
|
|
|
|
|
content: { msgtype: 'm.text', body: 'again', 'io.lotus.forwarded': original },
|
|
|
|
|
});
|
|
|
|
|
const content = buildForwardContent(mx, mEvent);
|
|
|
|
|
assert.ok(content);
|
|
|
|
|
assert.deepEqual(getForwardedMeta(content), original);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('getForwardedMeta rejects malformed metadata', () => {
|
|
|
|
|
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': 'nope' }), undefined);
|
|
|
|
|
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
|
|
|
|
assert.equal(getForwardedMeta({}), undefined);
|
|
|
|
|
});
|
2026-10-17 23:40:58 -04:00
|
|
|
|
|
|
|
|
test('forwardEncryptionWarning: only from an encrypted room into unencrypted ones (#330)', () => {
|
|
|
|
|
assert.equal(forwardEncryptionWarning(false, ['general']), undefined);
|
|
|
|
|
assert.equal(forwardEncryptionWarning(true, []), undefined);
|
|
|
|
|
assert.match(
|
|
|
|
|
forwardEncryptionWarning(true, ['general']) ?? '',
|
|
|
|
|
/^general isn't encrypted\..*that room, along with who originally wrote it\.$/,
|
|
|
|
|
);
|
|
|
|
|
assert.match(
|
|
|
|
|
forwardEncryptionWarning(true, ['a', 'b']) ?? '',
|
|
|
|
|
/^2 of the rooms you picked aren't encrypted\..*those rooms/,
|
|
|
|
|
);
|
|
|
|
|
});
|