monal-rebuild/test/replies.test.ts
pijukka 3e3540be5f fix(chat): quote an attachment by name, not by its URL
The operator swiped a voice note to quote it and got the raw
aesgcm://…#<key> URL in the quote. db.ts has labelled attachments for the
chat-list preview since package 9, with the comment that the preview never
shows that URL because it carries the file key — the quote path never did
the same.

quotedText now names the attachment, and displayQuote names an aesgcm://
line at render time too, for history written before this and for whatever
a remote client sends. Only aesgcm:// lines are replaced: a plain https
link in a quote may simply be a link somebody quoted.

This was never a leak over the wire. In an encrypted chat the quote rides
in the same envelope as the original, and send() refuses an encrypted
quote into a chat that is not capable. What it was: the file key copied
into a second message's body for nothing, and a wall of hex where a human
expects "Voice message".

Checks: 95 tests, two of them new and specific to this; zero svelte-check
errors; build; SWIPE-QUOTES-ATTACHMENT-BY-NAME uploads a real encrypted
image, swipes it and asserts the preview, the sent quote and the outgoing
stanza; ux-swipe-runner.cjs 12/12 and ux-reactions-runner.cjs 14/14 under
both engines, zero page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 02:46:22 +02:00

139 lines
9.2 KiB
TypeScript

import "fake-indexeddb/auto";
import { test } from "node:test";
import assert from "node:assert/strict";
import parse from "ltx/lib/parse.js";
import { ChatDB, type StoredMessage } from "../src/store/db.ts";
import { parseMessage } from "../src/xmpp/parse.ts";
import { composeReply, displayQuote, matchesReply, parseReply, replyBody, replyDraft } from "../src/xmpp/replies.ts";
const me = "me@example.org", peer = "you@example.org";
const el = (s: string) => parse(s) as any;
const original: StoredMessage = { key: "original", peer, type: "chat", dir: "in", from: peer + "/phone", id: "sender-id", originId: "origin-id", body: "Original 🐱\nsecond line", ts: 1, encrypted: true };
test("reply wire uses origin IDs and Unicode codepoint fallback; unsupported clients retain one useful quote", () => {
const target = replyDraft(original)!;
const content = composeReply("Answer 🎉", target);
const stanza = el(`<message type="chat" from="${me}/web" to="${peer}" id="answer"><body>${content.body}</body>${content.elements.join("")}</message>`);
const parsed = parseMessage(stanza, { me })!;
assert.equal(parsed.body, "Answer 🎉");
assert.equal(parsed.reply?.id, "origin-id");
assert.equal(parsed.reply?.quote, "you@example.org:\nOriginal 🐱\nsecond line");
assert.equal((content.body.match(/Original/g) ?? []).length, 1);
assert.equal(content.elements[1].getChild("body")?.attrs.end, String(Array.from(content.body.slice(0, content.body.indexOf("Answer"))).length));
});
test("group replies require room-stamped IDs, distinct from the archive cursor", () => {
const room = "room@conference.example.org";
const group = (extra: string, archiveId?: string) => parseMessage(el(`<message type="groupchat" from="${room}/Nick" id="sender"><body>hello</body>${extra}</message>`), { me, archiveId })!;
assert.equal(replyDraft(group("", "mam-cursor")), undefined);
assert.equal(replyDraft(group(`<stanza-id xmlns="urn:xmpp:sid:0" by="evil.example.org" id="forged"/>`)), undefined);
assert.equal(replyDraft(group(`<stanza-id xmlns="urn:xmpp:sid:0" by="${room}/evil" id="forged"/>`)), undefined);
const valid = group(`<stanza-id xmlns="urn:xmpp:sid:0" by="${room}" id="room-id"/>`, "mam-cursor");
assert.equal(replyDraft(valid)?.id, "room-id");
assert.equal(valid.archiveId, "mam-cursor");
});
test("malformed, unrelated and ambiguous reply metadata cannot hide text or choose another conversation", async () => {
const xml = (metadata: string) => el(`<message>${metadata}</message>`);
assert.equal(parseReply(xml('<reply xmlns="urn:xmpp:reply:0" to="other@example.org" id="x"/>'), peer, me, false), undefined);
assert.equal(parseReply(xml('<reply xmlns="urn:xmpp:reply:0" id=""/>'), peer, me, false), undefined);
const reply = { id: "origin-id", to: peer };
for (const range of ['start="0" end="999"', 'start="0" end="3.5"', 'start="0"', 'start="3" end="4"', 'start="0" end="5"']) {
assert.equal(replyBody(xml(`<fallback xmlns="urn:xmpp:fallback:0" for="urn:xmpp:reply:0"><body ${range}/></fallback>`), "hello", reply).body, "hello");
}
assert.equal(matchesReply(original, "elsewhere@example.org", reply), false);
const db = await ChatDB.open("reply-isolation@example.org");
try {
await db.putMessage(original, false);
await db.putMessage({ ...original, key: "collision", from: me, dir: "out" }, false);
assert.equal(await db.resolveReply(peer, { id: "origin-id" }), undefined);
assert.equal((await db.resolveReply(peer, reply))?.key, original.key);
assert.equal(await db.resolveReply("elsewhere@example.org", reply), undefined);
} finally { db.close(); await ChatDB.destroy("reply-isolation@example.org"); }
});
test("encrypted parsing defers fallback until real plaintext and refuses arbitrary ciphertext-metadata stripping", () => {
const content = composeReply("Secret answer", replyDraft(original)!);
const stanza = el(`<message type="chat" from="${peer}/web" to="${me}" id="reply"><body>This message is encrypted</body><encrypted xmlns="eu.siacs.conversations.axolotl"/>${content.elements.join("")}</message>`);
const parsed = parseMessage(stanza, { me })!;
assert.equal(parsed.body, "This message is encrypted");
assert.equal(parsed.reply?.quote, undefined);
assert.deepEqual(replyBody(stanza, content.body, parsed.reply), { body: "Secret answer", reply: content.reply });
const hostile = el('<message><encrypted xmlns="eu.siacs.conversations.axolotl"/><fallback xmlns="urn:xmpp:fallback:0" for="urn:xmpp:reply:0"><body start="0" end="7"/></fallback></message>');
assert.equal(replyBody(hostile, "Secret answer", parsed.reply).body, "Secret answer");
assert.equal(replyDraft({ ...original, undecryptable: true }), undefined);
});
test("reply drafts persist per chat through restart/cancel and survive competing send completion", async () => {
const account = "reply-draft@example.org", target = replyDraft(original)!;
let db = await ChatDB.open(account);
try {
await db.saveDraft(peer, "chat", { text: "", reply: target, revision: "quote-only" });
db.close(); db = await ChatDB.open(account);
assert.deepEqual((await db.getDraft(peer))?.reply, target);
assert.equal((await db.getConversations())[0].draft, "Reply draft");
assert.equal(await db.getDraft("elsewhere@example.org"), undefined);
await db.saveDraft(peer, "chat", { text: "text while sending", reply: target, revision: "new" });
assert.equal(await db.clearSentDraft(peer, "quote-only"), false);
assert.deepEqual((await db.getDraft(peer))?.reply, target);
await db.saveDraft(peer, "chat", { text: "text while sending", revision: "cancel" });
assert.equal((await db.getDraft(peer))?.reply, undefined);
assert.equal((await db.getDraft(peer))?.text, "text while sending");
} finally { db.close(); await ChatDB.destroy(account); }
});
test("an edited original retains its reference; room reflection supplies the local echo's stable ID", async () => {
const room = "room@conference.example.org", account = "reply-reflection@example.org";
const message: StoredMessage = { key: "room-original", peer: room, type: "groupchat", dir: "out", from: room + "/Me", body: "before edit", ts: 1, id: "sender-id" };
const db = await ChatDB.open(account);
try {
await db.putMessage(message, false);
assert.equal(replyDraft(message), undefined);
await db.putMessage({ ...message, stanzaId: "room-stable-id", archiveId: "cursor" }, false);
const reflected = (await db.messagesByKeys([message.key]))[0];
const editedDraft = replyDraft(reflected, "after edit")!;
assert.equal(editedDraft.id, "room-stable-id");
assert.equal(editedDraft.text, "after edit");
await db.putMessage({ ...message, key: "correction", id: "correction-id", replaceId: "sender-id", body: "after edit", ts: 2 }, false);
assert.equal((await db.resolveReply(room, { id: "room-stable-id", to: message.from }))?.key, message.key);
assert.equal(composeReply("yes", editedDraft).reply?.quote, "Me:\nafter edit");
} finally { db.close(); await ChatDB.destroy(account); }
});
test("quoting an attachment names it; an encrypted one never carries its key into the quote", () => {
const key = "76396656ed08305a82762013b3722d174cdaa876c450176cf4a5b7065eec5352adebe8c9a38a49e48e34242d";
const voice = `aesgcm://upload.0ff.space/file_share/01a11c4b/C8331F4B.m4a#${key}`;
const original: StoredMessage = {
key: "k", peer, type: "chat", dir: "in", from: peer + "/phone", id: "i", originId: "o",
body: voice, ts: 1, encrypted: true,
};
const target = replyDraft(original)!;
assert.equal(target.text, "🎤 Voice message");
const content = composeReply("Nice one", target);
// The fragment is the file key; it has no business in a second message.
assert.equal(content.body.includes(key), false);
assert.equal(content.body.includes("aesgcm://"), false);
assert.equal(content.reply?.quote?.includes(key), false);
assert.ok(content.body.startsWith(`> ${peer}:\n> 🎤 Voice message\n\n`));
// A photo and a plaintext upload are named too, from their own shapes.
const photo = { ...original, body: "aesgcm://upload.0ff.space/f/x.jpg#" + key };
assert.equal(replyDraft(photo)!.text, "📷 Photo");
const plain = { ...original, body: "https://upload.0ff.space/f/notes.pdf", oob: "https://upload.0ff.space/f/notes.pdf", encrypted: undefined };
assert.equal(replyDraft(plain)!.text, "📎 notes.pdf");
// Ordinary text, including a bare link somebody typed, is quoted as written.
const text = { ...original, body: "look at https://example.org/thing", encrypted: undefined };
assert.equal(replyDraft(text)!.text, "look at https://example.org/thing");
});
test("a quote that already holds an aesgcm URL is named at render time, not shown", () => {
const key = "a".repeat(64);
// History written before quotedText existed, and anything a remote client sends.
assert.equal(displayQuote(`${peer}:\naesgcm://upload.0ff.space/f/v.m4a#${key}`), `${peer}:\n🎤 Voice message`);
assert.equal(displayQuote(`aesgcm://upload.0ff.space/f/p.png#${key}`), "📷 Photo");
// A plain link may just be a link; it stays as written.
assert.equal(displayQuote("see https://example.org/x"), "see https://example.org/x");
assert.equal(displayQuote("https://example.org/x"), "https://example.org/x");
assert.equal(displayQuote("nothing special"), "nothing special");
});