Files
Haven/test/ferryForums.test.js

664 lines
39 KiB
JavaScript

/**
* Ferry forum bridging: a Discord forum pairs with a Haven forum, posts and
* replies cross both ways, and which topic is which post survives a restart.
*
* Needs a running Haven server. Boots one on a scratch port and data dir,
* pointed at the stand-in Discord in test/fakeDiscord.js, so no real bot,
* token or network is involved:
* node --test test/ferryForums.test.js
*/
const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { spawn } = require('node:child_process');
const { io } = require('socket.io-client');
const {
startFakeDiscord, GUILD_ID, TEXT_ID, FORUM_ID, TAG_BUG, TAG_STAFF,
} = require('./fakeDiscord');
const PORT = 3412;
const DISCORD_PORT = 3413;
const BASE = `http://localhost:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-ferry-forums-${Date.now()}`);
// Shaped like a bot token so the format check passes. It only ever reaches
// the fake Discord on this machine.
const FAKE_TOKEN = 'FAKE' + 'x'.repeat(66);
let server;
let discord;
const post = (p, body) => new Promise((res, rej) => {
const d = JSON.stringify(body);
const r = http.request({ host: 'localhost', port: PORT, path: p, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
r.on('error', rej); r.write(d); r.end();
});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function connect(token) {
const s = io(BASE, { auth: { token }, transports: ['websocket'], forceNew: true });
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
}
function next(sock, event, filter = () => true, ms = 4000) {
return new Promise((res) => {
const t = setTimeout(() => { sock.off(event, h); res(null); }, ms);
const h = (data) => { if (!filter(data)) return; clearTimeout(t); sock.off(event, h); res(data); };
sock.on(event, h);
});
}
function history(sock, code) {
const p = next(sock, 'message-history', (d) => d && d.channelCode === code);
sock.emit('get-messages', { code });
return p;
}
function threadOf(sock, parentId) {
const p = next(sock, 'thread-messages', (d) => d && d.parentId === parentId);
sock.emit('get-thread-messages', { parentId });
return p;
}
async function until(fn, ms = 6000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await wait(100);
}
return null;
}
// Uploads through Haven's own endpoint, so the server records who owns it.
// Answers with the stored name.
async function uploadFile(token, name, size, scope = 'channel') {
const fd = new FormData();
fd.append('scope', scope);
fd.append('file', new Blob([Buffer.alloc(size, 9)], { type: 'text/plain' }), name);
const r = await fetch(`${BASE}/api/upload-file`, { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd });
const j = await r.json();
return j && typeof j.url === 'string' ? j.url.replace('/uploads/', '') : null;
}
// Files written straight into uploads/ are news to the media index; the admin
// rebuilds it, as anyone adding files by hand would.
async function reindex(token) {
const r = await fetch(`${BASE}/api/admin/media/reconcile`, { method: 'POST', headers: { Authorization: 'Bearer ' + token } });
assert.strictEqual(r.status, 200, 'media index rebuilt');
}
const tagsOf = (m) => (Array.isArray(m.tags) ? m.tags : JSON.parse(m.tags || '[]'));
async function bootServer() {
server = spawn(process.execPath, ['server.js'], {
cwd: path.join(__dirname, '..'),
env: {
...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'admin', FORCE_HTTP: 'true',
// Ferry only honours the stand-in Discord addresses under NODE_ENV=test.
NODE_ENV: 'test',
FERRY_TEST_DISCORD_API: `http://127.0.0.1:${DISCORD_PORT}`,
FERRY_TEST_DISCORD_GATEWAY: `ws://127.0.0.1:${DISCORD_PORT}`,
},
stdio: 'ignore',
});
for (let i = 0; i < 60; i++) {
try {
await new Promise((res, rej) => http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
return;
} catch { await wait(500); }
}
throw new Error('server did not start');
}
function stopServer() {
return new Promise((res) => {
if (!server || server.exitCode !== null) return res();
server.once('exit', () => res());
server.kill();
});
}
test.before(async () => {
fs.mkdirSync(DATA, { recursive: true });
discord = await startFakeDiscord(DISCORD_PORT);
await bootServer();
});
test.after(async () => {
await stopServer();
await discord?.close();
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {}
});
const ALICE = { id: '1300000000000000100', username: 'alice', global_name: 'Alice', avatar: null, discriminator: '0' };
const BOB = { id: '1300000000000000101', username: 'bob', global_name: 'Bob', avatar: null, discriminator: '0' };
function discordMessage(id, channelId, author, content, extra = {}) {
return { id, channel_id: channelId, guild_id: GUILD_ID, type: 0, content, author, attachments: [], embeds: [], mentions: [], ...extra };
}
function discordThread(id, name, appliedTags = [], extra = {}) {
return { id, guild_id: GUILD_ID, parent_id: FORUM_ID, type: 11, name, applied_tags: appliedTags,
thread_metadata: { archived: false, locked: false }, ...extra };
}
test('a Discord forum and a Haven forum carry posts and replies both ways', async (t) => {
const admin = await post('/api/auth/register', { username: 'admin', password: 'ferryforums123', eulaVersion: '2.0', ageVerified: true });
assert.ok(admin.token, 'admin registered');
let A = await connect(admin.token);
const errors = [];
A.on('error-msg', (m) => errors.push(m));
// ── Ferry connected to the fake Discord ──
const tokenOk = next(A, 'ferry:token-ok');
A.emit('ferry:set-token', { token: FAKE_TOKEN });
assert.ok(await tokenOk, 'the fake Discord accepted the token');
A.emit('ferry:set-option', { key: 'ferry_enabled', value: true });
const getConfig = async () => {
const p = next(A, 'ferry:config');
A.emit('ferry:get-config');
return p;
};
const connected = await until(async () => {
const c = await getConfig();
return c && c.state.connected && c.guilds.length ? c : null;
}, 10000);
assert.ok(connected, 'Ferry connected to the fake gateway');
const dChannels = connected.guilds[0].channels;
assert.strictEqual(dChannels.find((c) => c.id === FORUM_ID).forum, true, 'the Discord forum is offered and marked as a forum');
assert.strictEqual(dChannels.find((c) => c.id === TEXT_ID).forum, false);
assert.strictEqual(dChannels.find((c) => c.id === TEXT_ID).hidden, false, 'a public channel is not flagged');
// A private channel the bot's role was never added to is flagged for the
// pairing form, and the flag clears once Discord lets the bot back in.
const textWith = (overwrites) => ({ id: TEXT_ID, name: 'general', type: 0, parent_id: null, guild_id: GUILD_ID, permission_overwrites: overwrites });
discord.dispatch('CHANNEL_UPDATE', textWith([{ id: GUILD_ID, type: 0, allow: '0', deny: '1024' }]));
assert.ok(await until(async () => (await getConfig()).guilds[0].channels.find((c) => c.id === TEXT_ID).hidden), 'a private channel is flagged');
discord.dispatch('CHANNEL_UPDATE', textWith([]));
assert.ok(await until(async () => !(await getConfig()).guilds[0].channels.find((c) => c.id === TEXT_ID).hidden), 'the flag clears');
// ── A Haven forum and a Haven chat channel ──
let list = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'help'));
A.emit('create-channel', { name: 'help', isForum: true });
let channels = await list;
const forum = channels.find((c) => c.name === 'help');
assert.strictEqual(forum.is_forum, 1);
list = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'chat'));
A.emit('create-channel', { name: 'chat' });
channels = await list;
const chat = channels.find((c) => c.name === 'chat');
A.emit('set-forum-tags', { code: forum.code, tags: [{ name: 'bug' }, { name: 'Staff' }] });
await wait(200);
A.emit('enter-channel', { code: forum.code });
await wait(200);
await t.test('a forum only pairs with a forum', async () => {
let err = next(A, 'error-msg');
A.emit('ferry:create-link', { channelCode: chat.code, guildId: GUILD_ID, discordChannelId: FORUM_ID, direction: 'both' });
assert.match(await err || '', /Haven forum/);
err = next(A, 'error-msg');
A.emit('ferry:create-link', { channelCode: forum.code, guildId: GUILD_ID, discordChannelId: TEXT_ID, direction: 'both' });
assert.match(await err || '', /Discord forum/);
const cfg = next(A, 'ferry:config', (c) => c.links.length === 1);
A.emit('ferry:create-link', { channelCode: forum.code, guildId: GUILD_ID, discordChannelId: FORUM_ID, direction: 'both', outMode: 'command' });
const c = await cfg;
assert.ok(c, 'the forum pairing was made');
assert.strictEqual(c.links[0].discord_channel_type, 15);
assert.strictEqual(c.links[0].out_mode, 'all', 'a forum pairing always mirrors');
});
const TH1 = discord.newId();
let topic1;
await t.test('a Discord post becomes a Haven topic', async () => {
discord.dispatch('THREAD_CREATE', discordThread(TH1, 'Crash on start', [TAG_BUG, TAG_STAFF], { newly_created: true }));
discord.dispatch('MESSAGE_CREATE', discordMessage(TH1, TH1, ALICE, 'It crashes right away',
{ attachments: [{ url: 'https://cdn.discordapp.com/attachments/1/2/shot.png' }] }));
topic1 = await until(async () => (await history(A, forum.code)).messages.find((m) => m.title === 'Crash on start'));
assert.ok(topic1, 'the topic arrived');
assert.match(topic1.content, /It crashes right away/);
assert.match(topic1.content, /shot\.png/, 'the attachment came along as a link');
assert.deepStrictEqual(tagsOf(topic1).sort(), ['Staff', 'bug'], 'tags matched by name, Haven spelling kept');
assert.ok(topic1.is_webhook, 'shown as a relayed author');
assert.strictEqual(topic1.webhook_username, 'Alice');
});
let TH_CONFIG;
await t.test('a post whose first message beats its THREAD_CREATE still arrives', async () => {
const TH = TH_CONFIG = discord.newId();
discord.dispatch('MESSAGE_CREATE', discordMessage(TH, TH, BOB, 'Where is the config file?'));
await wait(150);
discord.dispatch('THREAD_CREATE', discordThread(TH, 'Config location'));
const got = await until(async () => (await history(A, forum.code)).messages.find((m) => m.title === 'Config location'));
assert.ok(got);
});
await t.test('a Discord reply becomes a Haven reply, and its edit follows', async () => {
const id = discord.newId();
const live = next(A, 'new-thread-message', (d) => d && d.parentId === topic1.id && d.message.content === 'Same here');
discord.dispatch('MESSAGE_CREATE', discordMessage(id, TH1, BOB, 'Same here'));
const liveMsg = await live;
assert.ok(liveMsg, 'the reply was broadcast live');
assert.strictEqual(liveMsg.message.username, '[BOT] Bob', 'marked as relayed when it arrives');
assert.strictEqual(liveMsg.message.webhook_username, 'Bob', 'the stored name stays bare');
const thread = await until(async () => {
const th = await threadOf(A, topic1.id);
return th.messages.some((m) => m.content === 'Same here') ? th : null;
});
assert.ok(thread, 'the reply landed in the topic');
const reply = thread.messages.find((m) => m.content === 'Same here');
assert.strictEqual(reply.username, '[BOT] Bob', 'marked as relayed in thread history, prefixed once');
assert.strictEqual(thread.parentUsername, '[BOT] Alice', 'the relayed topic author is marked too');
discord.dispatch('MESSAGE_UPDATE', { id, channel_id: TH1, guild_id: GUILD_ID, content: 'Same here, on Linux', author: BOB, mentions: [] });
assert.ok(await until(async () => (await threadOf(A, topic1.id)).messages.find((m) => m.content === 'Same here, on Linux')));
});
let topic2;
let TH2;
await t.test('a Haven topic becomes a Discord post through the webhook', async () => {
const before = discord.executions.length;
A.emit('send-message', { code: forum.code, content: 'Haven body text', title: 'From Haven', tags: ['bug'] });
const exec = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.thread_name));
assert.ok(exec, 'Discord was asked to make a post');
assert.strictEqual(exec.body.thread_name, 'From Haven');
assert.strictEqual(exec.body.content, 'Haven body text');
assert.strictEqual(exec.body.username, 'admin', 'shown as the Haven author');
assert.deepStrictEqual(exec.body.applied_tags, [TAG_BUG]);
assert.strictEqual(exec.threadId, null, 'a new post, not a reply');
topic2 = (await history(A, forum.code)).messages.find((m) => m.title === 'From Haven');
assert.ok(topic2);
});
await t.test('a Haven reply goes into that post with thread_id', async () => {
const before = discord.executions.length;
await new Promise((res) => A.emit('send-thread-message', { parentId: topic2.id, content: 'reply from haven' }, res));
const exec = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.content === 'reply from haven'));
assert.ok(exec, 'the reply went to Discord');
assert.ok(exec.threadId, 'into a thread');
assert.ok(!exec.body.thread_name, 'not as a new post');
// The fake Discord names a new post after its first message, which is
// what the webhook call that made it answered with.
TH2 = exec.threadId;
});
await t.test('our own relays echoing back from Discord are not copied again', async () => {
const hookId = [...discord.webhooks.values()][0].id;
// With other bots relayed, our own webhook id is the only thing standing
// between an echo and a duplicate, so that is the guard under test.
A.emit('ferry:set-option', { key: 'ferry_relay_bots', value: true });
await wait(300);
const sentBefore = discord.executions.length;
discord.dispatch('THREAD_CREATE', discordThread(TH2, 'From Haven', [TAG_BUG], { newly_created: true }));
discord.dispatch('MESSAGE_CREATE', discordMessage(TH2, TH2, { id: hookId, username: 'admin', bot: true }, 'Haven body text', { webhook_id: hookId }));
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), TH2, { id: hookId, username: 'admin', bot: true }, 'reply from haven', { webhook_id: hookId }));
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), TH2, ALICE, 'answer from discord'));
const thread = await until(async () => {
const th = await threadOf(A, topic2.id);
return th.messages.some((m) => m.content === 'answer from discord') ? th : null;
});
assert.ok(thread, 'the real Discord reply arrived');
assert.deepStrictEqual(thread.messages.map((m) => m.content), ['reply from haven', 'answer from discord'], 'no echoed copies');
const topics = (await history(A, forum.code)).messages.filter((m) => m.title === 'From Haven');
assert.strictEqual(topics.length, 1, 'the echoed post did not become a second topic');
await wait(300);
assert.strictEqual(discord.executions.length, sentBefore, 'nothing from Discord was sent back to Discord');
});
await t.test('the links survive a restart', async () => {
A.close();
const identifiedBefore = discord.identified;
await stopServer();
await bootServer();
A = await connect(admin.token);
A.on('error-msg', (m) => errors.push(m));
A.emit('enter-channel', { code: forum.code });
assert.ok(await until(() => discord.identified > identifiedBefore && discord.connections > 0, 10000), 'Ferry reconnected');
await wait(300);
// Discord to Haven: the fake guild sends no threads on connect, so only
// the stored link can route this.
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), TH1, ALICE, 'after the restart'));
assert.ok(await until(async () => (await threadOf(A, topic1.id)).messages.find((m) => m.content === 'after the restart')));
// Haven to Discord.
const before = discord.executions.length;
await new Promise((res) => A.emit('send-thread-message', { parentId: topic2.id, content: 'still linked' }, res));
const exec = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.content === 'still linked'));
assert.ok(exec);
assert.strictEqual(exec.threadId, TH2);
});
await t.test('renaming, retagging and locking a Discord post update its topic', async () => {
// After the restart Ferry has no earlier state for these posts (the fake
// guild sends no threads), so their first update only teaches it the
// current state and changes nothing in Haven.
discord.dispatch('THREAD_UPDATE', discordThread(TH1, 'Something else', [], { thread_metadata: { archived: true, locked: true } }));
discord.dispatch('THREAD_UPDATE', discordThread(TH_CONFIG, 'Config location', []));
await wait(500);
const untouched = (await history(A, forum.code)).messages.find((m) => m.id === topic1.id);
assert.strictEqual(untouched.title, 'Crash on start', 'an update with nothing to compare against is not applied');
assert.ok(!untouched.closed);
discord.dispatch('THREAD_UPDATE', discordThread(TH1, 'Crash on start', [TAG_BUG, TAG_STAFF]));
await wait(300);
assert.deepStrictEqual(tagsOf((await history(A, forum.code)).messages.find((m) => m.id === topic1.id)).sort(), ['Staff', 'bug']);
discord.dispatch('THREAD_UPDATE', discordThread(TH1, 'Crash on start (solved)', []));
const renamed = await until(async () => (await history(A, forum.code)).messages
.find((m) => m.id === topic1.id && m.title === 'Crash on start (solved)'));
assert.ok(renamed, 'retitled');
assert.deepStrictEqual(tagsOf(renamed), [], 'retagged');
assert.ok(!renamed.closed, 'still open');
discord.dispatch('THREAD_UPDATE', discordThread(TH_CONFIG, 'Config location', [], { thread_metadata: { archived: true, locked: true } }));
assert.ok(await until(async () => (await history(A, forum.code)).messages.find((m) => m.title === 'Config location' && m.closed)), 'locked closes it');
});
await t.test('Discord archiving a post does not undo Haven moderation of its topic', async () => {
const TH = discord.newId();
discord.dispatch('THREAD_CREATE', discordThread(TH, 'Printer jam', [TAG_BUG], { newly_created: true }));
discord.dispatch('MESSAGE_CREATE', discordMessage(TH, TH, BOB, 'Paper stuck again'));
const topic = await until(async () => (await history(A, forum.code)).messages.find((m) => m.title === 'Printer jam'));
assert.ok(topic);
// A Haven moderator retitles, retags and closes it.
const updated = next(A, 'topic-updated', (d) => d && d.messageId === topic.id);
A.emit('set-topic-meta', { messageId: topic.id, title: 'Printer jam (see FAQ)', tags: ['Staff'], closed: true });
assert.ok(await updated);
// Discord archives the quiet post, then a message wakes it up again.
discord.dispatch('THREAD_UPDATE', discordThread(TH, 'Printer jam', [TAG_BUG], { thread_metadata: { archived: true, locked: false } }));
discord.dispatch('THREAD_UPDATE', discordThread(TH, 'Printer jam', [TAG_BUG]));
await wait(500);
let now = (await history(A, forum.code)).messages.find((m) => m.id === topic.id);
assert.strictEqual(now.title, 'Printer jam (see FAQ)', 'the Haven title stays');
assert.deepStrictEqual(tagsOf(now), ['Staff'], 'the Haven tags stay');
assert.ok(now.closed, 'the topic stays closed');
// A real rename on Discord still comes across, and only the name changes.
discord.dispatch('THREAD_UPDATE', discordThread(TH, 'Printer jam fixed', [TAG_BUG]));
now = await until(async () => (await history(A, forum.code)).messages.find((m) => m.id === topic.id && m.title === 'Printer jam fixed'));
assert.ok(now, 'the Discord rename applied');
assert.deepStrictEqual(tagsOf(now), ['Staff'], 'tags Discord did not change stay as Haven set them');
assert.ok(now.closed, 'still closed, Discord did not unlock anything');
});
await t.test('an NSFW Haven topic is not sent to a Discord forum that is not age-restricted', async () => {
const before = discord.executions.length;
const held = next(A, 'error-msg', (m) => /NSFW/.test(m || ''));
A.emit('send-message', { code: forum.code, content: 'Not for work', title: 'Spicy', nsfw: true });
assert.match(await held || '', /age-restricted/, 'the author is told it stayed in Haven');
const topic = await until(async () => (await history(A, forum.code)).messages.find((m) => m.title === 'Spicy'));
assert.ok(topic && topic.nsfw, 'the topic was made in Haven');
const cfg = next(A, 'ferry:config');
A.emit('ferry:get-config');
assert.match((await cfg).links[0].last_error || '', /NSFW/, 'the pairing says why');
await new Promise((res) => A.emit('send-thread-message', { parentId: topic.id, content: 'spicy reply' }, res));
await wait(500);
assert.ok(!discord.executions.slice(before).some((e) => e.body && (e.body.thread_name === 'Spicy' || e.body.content === 'spicy reply')),
'neither the topic nor its reply reached Discord');
// Once Discord says the forum is age-restricted, NSFW topics go through.
discord.dispatch('CHANNEL_UPDATE', { ...discord.forumChannel(), guild_id: GUILD_ID, nsfw: true });
await wait(200);
A.emit('send-message', { code: forum.code, content: 'Also not for work', title: 'Spicy two', nsfw: true });
assert.ok(await until(() => discord.executions.slice(before).find((e) => e.body && e.body.thread_name === 'Spicy two')),
'sent to an age-restricted forum');
discord.dispatch('CHANNEL_UPDATE', { ...discord.forumChannel(), guild_id: GUILD_ID });
await wait(200);
});
await t.test('a post deleted on Discord closes its topic and unlinks it', async () => {
discord.dispatch('THREAD_DELETE', { id: TH1, guild_id: GUILD_ID, parent_id: FORUM_ID, type: 11 });
const closed = await until(async () => (await history(A, forum.code)).messages.find((m) => m.id === topic1.id && m.closed));
assert.ok(closed, 'the topic is closed');
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), TH1, BOB, 'into the void'));
await wait(500);
const th = await threadOf(A, topic1.id);
assert.ok(!th.messages.some((m) => m.content === 'into the void'));
});
await t.test('a topic deleted in Haven is unlinked and stays deleted', async () => {
A.emit('delete-message', { messageId: topic2.id, channelCode: forum.code });
await until(async () => !(await history(A, forum.code)).messages.some((m) => m.id === topic2.id));
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), TH2, BOB, 'late reply'));
await wait(500);
const topics = (await history(A, forum.code)).messages.filter((m) => m.title === 'From Haven');
assert.strictEqual(topics.length, 0, 'the reply did not bring the topic back');
});
// #5773: replies in a thread of a paired Discord chat channel land in a
// Haven thread, under the copy of the message the thread started from.
await t.test('a thread in a paired chat channel becomes a Haven thread', async () => {
const cfg = next(A, 'ferry:config', (c) => c.links.some((l) => l.discord_channel_id === TEXT_ID));
A.emit('ferry:create-link', { channelCode: chat.code, guildId: GUILD_ID, discordChannelId: TEXT_ID, direction: 'both' });
assert.ok(await cfg, 'the chat pairing was made');
const START = discord.newId();
discord.dispatch('MESSAGE_CREATE', discordMessage(START, TEXT_ID, ALICE, 'Anyone tried the new build?'));
const copy = await until(async () => (await history(A, chat.code)).messages.find((m) => m.content === 'Anyone tried the new build?'));
assert.ok(copy, 'the chat line came across');
discord.dispatch('THREAD_CREATE', { id: START, guild_id: GUILD_ID, parent_id: TEXT_ID, type: 11, name: 'New build',
thread_metadata: { archived: false, locked: false } });
await wait(200);
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), START, BOB, 'Works for me'));
const reply = await until(async () => (await threadOf(A, copy.id)).messages.find((m) => m.content === 'Works for me'));
assert.ok(reply, 'the thread reply hangs from the message the thread started from');
const chatAfter = (await history(A, chat.code)).messages;
assert.ok(!chatAfter.some((m) => m.content === 'Works for me'), 'the reply is not a chat line');
// A thread started on its own gets a line with its name to hang from.
const LONE = discord.newId();
discord.dispatch('THREAD_CREATE', { id: LONE, guild_id: GUILD_ID, parent_id: TEXT_ID, type: 11, name: 'Side chat',
thread_metadata: { archived: false, locked: false } });
await wait(200);
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), LONE, ALICE, 'first in the side chat'));
const root = await until(async () => (await history(A, chat.code)).messages.find((m) => m.content === '🧵 Side chat'));
assert.ok(root, 'a line names the thread');
const sideReply = await until(async () => (await threadOf(A, root.id)).messages.find((m) => m.content === 'first in the side chat'));
assert.ok(sideReply, 'the reply is in that thread');
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), LONE, BOB, 'second'));
await until(async () => (await threadOf(A, root.id)).messages.some((m) => m.content === 'second'));
const roots = (await history(A, chat.code)).messages.filter((m) => m.content === '🧵 Side chat');
assert.strictEqual(roots.length, 1, 'the next reply reuses the same line');
});
// A picture or file sent in Haven goes to Discord as an upload, not as a
// /uploads/ path Discord cannot open. One past Discord's 10 MB stays behind.
await t.test('Haven uploads go to Discord as attachments', async () => {
const uploads = path.join(DATA, 'uploads');
fs.mkdirSync(uploads, { recursive: true });
fs.writeFileSync(path.join(uploads, '1790000000000-aaaa1111.png'), Buffer.alloc(2048, 7));
fs.writeFileSync(path.join(uploads, '1790000000001-bbbb2222.pdf'), Buffer.alloc(4096, 3));
fs.writeFileSync(path.join(uploads, '1790000000002-cccc3333.mp4'), Buffer.alloc(11 * 1024 * 1024, 1));
await reindex(admin.token);
// Mirror everything from the chat pairing, so a plain message goes out.
const got = next(A, 'ferry:config');
A.emit('ferry:get-config');
const chatLink = (await got).links.find((l) => l.discord_channel_id === TEXT_ID);
const updated = next(A, 'ferry:config', (c) => c.links.some((l) => l.id === chatLink.id && l.out_mode === 'all'));
A.emit('ferry:update-link', { id: chatLink.id, outMode: 'all' });
assert.ok(await updated, 'the pairing mirrors every message');
A.emit('enter-channel', { code: chat.code });
await wait(200);
const before = discord.executions.length;
A.emit('send-message', { code: chat.code, content: 'Image send test.\n/uploads/1790000000000-aaaa1111.png' });
const pic = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.content === 'Image send test.'));
assert.ok(pic, 'the text went without the path');
assert.deepStrictEqual(pic.body.files.map((f) => [f.filename, f.size]), [['1790000000000-aaaa1111.png', 2048]]);
assert.deepStrictEqual(pic.body.attachments, [{ id: 0, filename: '1790000000000-aaaa1111.png' }]);
A.emit('send-message', { code: chat.code, content: '[file:Quarterly report.pdf](/uploads/1790000000001-bbbb2222.pdf|4 KB)' });
const doc = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.files && e.body.files.some((f) => f.filename === 'Quarterly report.pdf')));
assert.ok(doc, 'a file goes under its own name');
assert.strictEqual(doc.body.content, '');
A.emit('send-message', { code: chat.code, content: 'big one /uploads/1790000000002-cccc3333.mp4' });
const big = await until(() => discord.executions.slice(before).find((e) => e.body && /^big one/.test(e.body.content || '')));
assert.ok(big, 'the message still went');
assert.ok(!big.body.files, 'nothing over 10 MB was uploaded');
assert.strictEqual(big.body.content, 'big one (too large to send to Discord)');
});
await t.test('attachments addressed to Discord in an on-command pairing go along', async () => {
const uploads = path.join(DATA, 'uploads');
fs.writeFileSync(path.join(uploads, '1790000000003-dddd4444.png'), Buffer.alloc(1024, 5));
fs.writeFileSync(path.join(uploads, '1790000000004-eeee5555.png'), Buffer.alloc(512, 6));
await reindex(admin.token);
const got = next(A, 'ferry:config');
A.emit('ferry:get-config');
const chatLink = (await got).links.find((l) => l.discord_channel_id === TEXT_ID);
const updated = next(A, 'ferry:config', (c) => c.links.some((l) => l.id === chatLink.id && l.out_mode === 'command'));
A.emit('ferry:update-link', { id: chatLink.id, outMode: 'command' });
assert.ok(await updated, 'the pairing only sends what is addressed to it');
const before = discord.executions.length;
A.emit('send-message', { code: chat.code, content: '/uploads/1790000000003-dddd4444.png', isImage: true });
A.emit('send-message', { code: chat.code, content: '=>Test Guild#general /uploads/1790000000003-dddd4444.png', isImage: true, bundled: true });
const pic = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.files && e.body.files.some((f) => f.filename === '1790000000003-dddd4444.png')));
assert.ok(pic, 'the addressed picture was uploaded');
assert.strictEqual(discord.executions.slice(before).filter((e) => e.body && e.body.files).length, 1, 'the unaddressed one stayed in Haven');
A.emit('send-message', { code: chat.code, content: '=>#general spoiler-img:/uploads/1790000000004-eeee5555.png', isImage: true });
const spoiler = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.files && e.body.files.some((f) => f.filename === 'SPOILER_1790000000004-eeee5555.png')));
assert.ok(spoiler, 'a spoilered picture goes as a Discord spoiler');
assert.strictEqual(spoiler.body.content, '');
});
// Replies cross the bridge: a Discord reply to a Haven message is a Haven
// reply (and tells its author), and a Haven reply names on Discord what it
// answers, with a jump link and the Discord author.
await t.test('replies cross the bridge both ways', async () => {
const got = next(A, 'ferry:config');
A.emit('ferry:get-config');
const chatLink = (await got).links.find((l) => l.discord_channel_id === TEXT_ID);
const updated = next(A, 'ferry:config', (c) => c.links.some((l) => l.id === chatLink.id && l.out_mode === 'all'));
A.emit('ferry:update-link', { id: chatLink.id, outMode: 'all' });
assert.ok(await updated);
A.emit('enter-channel', { code: chat.code });
await wait(200);
// Haven -> Discord: replying to a message that came from Discord.
const FROM_ALICE = discord.newId();
discord.dispatch('MESSAGE_CREATE', discordMessage(FROM_ALICE, TEXT_ID, ALICE, 'Who has the patch notes?'));
const copy = await until(async () => (await history(A, chat.code)).messages.find((m) => m.content === 'Who has the patch notes?'));
assert.ok(copy);
let before = discord.executions.length;
A.emit('send-message', { code: chat.code, content: 'I do, posting them now', replyTo: copy.id });
const out = await until(() => discord.executions.slice(before).find((e) => e.body && /posting them now/.test(e.body.content || '')));
assert.ok(out, 'the reply went to Discord');
const [line, text] = out.body.content.split('\n');
assert.ok(line.startsWith(`-# ↩ <@${ALICE.id}>: Who has the patch notes?`), line);
assert.ok(line.includes(`https://discord.com/channels/${GUILD_ID}/${TEXT_ID}/${FROM_ALICE}`), 'with a jump link');
assert.strictEqual(text, 'I do, posting them now');
// Discord -> Haven: a Discord reply to a message sent from Haven.
before = discord.executions.length;
A.emit('send-message', { code: chat.code, content: 'Server restarts at noon' });
const sent = await until(() => discord.executions.slice(before).find((e) => e.body && e.body.content === 'Server restarts at noon'));
assert.ok(sent);
const sentId = sent.result && sent.result.id;
const outbound = await until(async () => (await history(A, chat.code)).messages.find((m) => m.content === 'Server restarts at noon'));
const told = next(A, 'new-message', (d) => d && d.message && d.message.content === 'Thanks for the heads up');
discord.dispatch('MESSAGE_CREATE', discordMessage(discord.newId(), TEXT_ID, BOB, 'Thanks for the heads up',
{ type: 19, message_reference: { message_id: sentId, channel_id: TEXT_ID, guild_id: GUILD_ID } }));
const live = await told;
assert.ok(live, 'the reply came across');
assert.strictEqual(live.message.reply_to, outbound.id, 'as a reply to the Haven message');
assert.strictEqual(live.message.replyContext.user_id, admin.user.id, 'so its author is told');
});
await t.test('a Discord DM carries Haven attachments as uploads', async () => {
const reconnected = discord.identified;
A.emit('ferry:set-option', { key: 'ferry_allow_dms', value: true });
await until(() => discord.identified > reconnected, 8000);
await wait(300);
const before = discord.botMessages.length;
A.emit('send-message', { code: chat.code, content: '=>@Alice here it is /uploads/1790000000003-dddd4444.png', ferryDiscordUserId: ALICE.id });
const dm = await until(() => discord.botMessages.slice(before)[0]);
assert.ok(dm, 'the DM was sent');
assert.match(dm.body.content, /here it is/);
assert.doesNotMatch(dm.body.content, /\/uploads\//, 'no Haven path in the DM text');
assert.deepStrictEqual(dm.body.files.map((f) => [f.filename, f.size]), [['1790000000003-dddd4444.png', 1024]]);
});
// A message names its files as plain paths, so anyone could name someone
// else's. Only the sender's own uploads go to Discord.
await t.test("only the sender's own uploads go to Discord", async () => {
const roles = (await getConfig()).roles;
const memberRole = roles.find((r) => r.name === 'Member');
const granted = next(A, 'ferry:config', (c) => c.roles.some((r) => r.id === memberRole.id && r.can_ferry));
A.emit('ferry:set-role-permission', { roleId: memberRole.id, allowed: true });
assert.ok(await granted, 'members may use Ferry');
const member = await post('/api/auth/register', { username: 'ferrymember', password: 'ownuploads123', eulaVersion: '2.0', ageVerified: true });
assert.ok(member.token, 'member registered');
const M = await connect(member.token);
const notices = [];
M.on('error-msg', (m) => notices.push(m));
M.emit('join-channel', { code: chat.code });
await wait(300);
M.emit('enter-channel', { code: chat.code });
await wait(200);
const mine = await uploadFile(member.token, 'mine.txt', 3000);
const theirs = await uploadFile(admin.token, 'theirs.txt', 2000);
const private_ = await uploadFile(member.token, 'private.bin', 1000, 'dm');
assert.ok(mine && theirs && private_, 'the uploads were made');
const OWNERLESS = '1790000000003-dddd4444.png'; // on disk with no owner on record
const OWN_NOTICE = /^Only your own uploads go to Discord, so 1 file stayed in Haven$/;
const sent = (before, fn) => until(() => discord.executions.slice(before).find(fn));
let before = discord.executions.length;
M.emit('send-message', { code: chat.code, content: `=>#general my notes [file:Notes.txt](/uploads/${mine}|3 KB)` });
const own = await sent(before, (e) => e.body && e.body.content === 'my notes');
assert.ok(own, "the member's message went");
assert.deepStrictEqual(own.body.files.map((f) => [f.filename, f.size]), [['Notes.txt', 3000]], 'with their own file');
M.emit('send-message', { code: chat.code, content: `=>#general spoiler-img:/uploads/${mine}` });
assert.ok(await sent(before, (e) => e.body && e.body.files && e.body.files.some((f) => f.filename === `SPOILER_${mine}`)), 'their own spoiler too');
assert.deepStrictEqual(notices, [], 'nothing to report');
before = discord.executions.length;
M.emit('send-message', { code: chat.code, content: `=>#general look at this /uploads/${theirs}` });
const other = await sent(before, (e) => e.body && e.body.content === 'look at this');
assert.ok(other, 'the text still went');
assert.ok(!other.body.files, "without someone else's file");
assert.doesNotMatch(other.body.content, /uploads/, 'not even as a link');
assert.ok(await until(() => notices.some((n) => OWN_NOTICE.test(n))), 'the member was told why');
notices.length = 0;
M.emit('send-message', { code: chat.code, content: `=>#general an old one /uploads/${OWNERLESS}` });
const old = await sent(before, (e) => e.body && e.body.content === 'an old one');
assert.ok(old && !old.body.files, 'a file with no owner on record stays in Haven');
assert.ok(await until(() => notices.some((n) => OWN_NOTICE.test(n))), 'and the member was told');
// A message that was only someone else's file sends nothing at all.
notices.length = 0;
M.emit('send-message', { code: chat.code, content: `=>#general [file:theirs.txt](/uploads/${theirs}|2 KB)` });
assert.ok(await until(() => notices.some((n) => OWN_NOTICE.test(n))), 'told once');
await wait(400);
assert.strictEqual(discord.executions.length - before, 2, 'nothing else reached Discord');
// The same in a Discord DM.
notices.length = 0;
const dmBefore = discord.botMessages.length;
M.emit('send-message', { code: chat.code, content: `=>@Alice see this /uploads/${theirs}`, ferryDiscordUserId: ALICE.id });
const dm = await until(() => discord.botMessages.slice(dmBefore)[0]);
assert.ok(dm, 'the DM went');
assert.match(dm.body.content, /see this/);
assert.ok(!dm.body.files, "without someone else's file");
assert.ok(await until(() => notices.some((n) => OWN_NOTICE.test(n))), 'the member was told');
// The admin may send any member's channel upload, but not their DM files.
before = discord.executions.length;
A.emit('send-message', { code: chat.code, content: `=>#general from a member /uploads/${mine}` });
const fromAdmin = await sent(before, (e) => e.body && e.body.content === 'from a member');
assert.ok(fromAdmin, "the admin's message went");
assert.deepStrictEqual(fromAdmin.body.files.map((f) => f.filename), [mine], "with the member's file");
const adminNotice = next(A, 'error-msg', (m) => OWN_NOTICE.test(m));
A.emit('send-message', { code: chat.code, content: `=>#general private /uploads/${private_}` });
assert.ok(await adminNotice, "a member's DM file is not the admin's to send");
const priv = await sent(before, (e) => e.body && e.body.content === 'private');
assert.ok(priv && !priv.body.files, 'it stayed in Haven');
errors.splice(0, errors.length, ...errors.filter((e) => !OWN_NOTICE.test(e)));
M.close();
});
assert.deepStrictEqual(errors.filter((e) => !/forum|Sent to Discord/i.test(e)), [], 'no unexpected errors');
A.close();
});