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>
139 lines
9.2 KiB
TypeScript
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");
|
|
});
|