Files
Haven/test/webhookBulkDelete.integration.test.js

443 lines
24 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const test = require('node:test');
const Database = require('better-sqlite3');
const WebSocket = require('ws');
async function availablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
server.close(error => error ? reject(error) : resolve(port));
});
});
}
async function waitForServer(url, child, output) {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Server exited early:\n${output()}`);
try {
const response = await fetch(url);
if (response.ok) return;
} catch {}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Server did not become ready:\n${output()}`);
}
function connectChannelSocket(baseUrl, token, channelCode) {
const socketUrl = baseUrl.replace(/^http/, 'ws') + '/socket.io/?EIO=4&transport=websocket';
const ws = new WebSocket(socketUrl);
const events = [];
const waiters = [];
function pushEvent(event, payload) {
events.push({ event, payload });
for (let index = waiters.length - 1; index >= 0; index--) {
const waiter = waiters[index];
if (waiter.event !== event || !waiter.predicate(payload)) continue;
waiters.splice(index, 1);
clearTimeout(waiter.timer);
waiter.resolve(payload);
}
}
const ready = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timed out joining Socket.IO channel')), 5000);
ws.on('message', raw => {
const packet = raw.toString();
if (packet === '2') return ws.send('3');
if (packet.startsWith('0')) {
ws.send(`40${JSON.stringify({ token })}`);
return;
}
if (packet.startsWith('44')) {
clearTimeout(timer);
reject(new Error(`Socket.IO authentication failed: ${packet}`));
return;
}
if (packet.startsWith('40')) {
ws.send(`42${JSON.stringify(['join-channel', { code: channelCode }])}`);
return;
}
if (!packet.startsWith('42')) return;
const [event, payload] = JSON.parse(packet.slice(2));
pushEvent(event, payload);
if (event === 'channel-joined' && payload?.code === channelCode) {
clearTimeout(timer);
resolve();
}
});
ws.once('error', reject);
});
function waitFor(event, predicate = () => true, timeoutMs = 3000) {
const existing = events.find(item => item.event === event && predicate(item.payload));
if (existing) return Promise.resolve(existing.payload);
return new Promise((resolve, reject) => {
const waiter = { event, predicate, resolve, timer: null };
waiter.timer = setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index !== -1) waiters.splice(index, 1);
reject(new Error(`Timed out waiting for ${event}`));
}, timeoutMs);
waiters.push(waiter);
});
}
function checkpoint() {
return events.length;
}
function eventsSince(index, event) {
return events.slice(index)
.filter(item => item.event === event)
.map(item => item.payload);
}
return { ws, ready, waitFor, checkpoint, eventsSince };
}
test('webhook bulk delete enforces moderation and cleans related data end to end', async t => {
const root = path.resolve(__dirname, '..');
const dataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'haven-webhook-bulk-delete-'));
const modToken = 'm'.repeat(64);
const noModToken = 'n'.repeat(64);
const port = await availablePort();
const env = {
...process.env,
HAVEN_DATA_DIR: dataDir,
FORCE_HTTP: 'true',
HOST: '127.0.0.1',
JWT_SECRET: 'b'.repeat(64),
PORT: String(port)
};
const seed = spawnSync(process.execPath, ['-e', `
const fs = require('fs');
const path = require('path');
const jwt = require('jsonwebtoken');
const { initDatabase } = require('./src/database');
const db = initDatabase();
const user = db.prepare("INSERT INTO users (username, password_hash, display_name, is_admin) VALUES ('owner', 'x', 'Owner', 1)").run();
const otherUser = db.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('other', 'x', 'Other')").run();
db.prepare("UPDATE users SET avatar = '/UPLOADS/%61vatar-protected.txt' WHERE id = ?").run(user.lastInsertRowid);
const channel = db.prepare("INSERT INTO channels (name, code, created_by, is_dm) VALUES ('Bots', 'abcd1234', ?, 0)").run(user.lastInsertRowid);
const otherChannel = db.prepare("INSERT INTO channels (name, code, created_by, is_dm) VALUES ('Other', 'abcd5678', ?, 0)").run(user.lastInsertRowid);
const dmChannel = db.prepare("INSERT INTO channels (name, code, created_by, is_dm) VALUES ('DM', 'abcd9012', ?, 1)").run(user.lastInsertRowid);
db.prepare('INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?)').run(channel.lastInsertRowid, user.lastInsertRowid);
db.prepare('INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?)').run(otherChannel.lastInsertRowid, user.lastInsertRowid);
db.prepare('INSERT INTO webhooks (channel_id, name, token, created_by, can_moderate) VALUES (?, ?, ?, ?, 1)').run(channel.lastInsertRowid, 'Mod Bot', '${modToken}', user.lastInsertRowid);
db.prepare('INSERT INTO webhooks (channel_id, name, token, created_by, can_moderate) VALUES (?, ?, ?, ?, 0)').run(channel.lastInsertRowid, 'Plain Bot', '${noModToken}', user.lastInsertRowid);
const insertMessage = db.prepare('INSERT INTO messages (channel_id, user_id, content, created_at) VALUES (?, ?, ?, ?)');
const survivorShared = insertMessage.run(channel.lastInsertRowid, user.lastInsertRowid, 'keep /uploads/local-shared.txt', '2026-01-01 00:00:00');
const survivorPlain = insertMessage.run(channel.lastInsertRowid, user.lastInsertRowid, 'keep this too', '2026-01-02 00:00:00');
const parent = insertMessage.run(
channel.lastInsertRowid,
user.lastInsertRowid,
'remove /uploads/local-shared.txt /uploads/global-shared.txt /uploads/slash-shared.txt /uploads/dot-shared.txt /uploads/windows-shared.txt /uploads/../escape-victim.txt /uploads/avatar-protected.txt /uploads/protected.mp3 /uploads/private.txt /uploads/dm-edited.txt',
'2026-01-03 00:00:00'
);
const threadReply = db.prepare('INSERT INTO messages (channel_id, user_id, content, thread_id, reply_to, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(
channel.lastInsertRowid,
user.lastInsertRowid,
'reply /uploads/orphan.txt',
parent.lastInsertRowid,
parent.lastInsertRowid,
'2026-01-03 00:01:00'
);
const inlineReply = db.prepare('INSERT INTO messages (channel_id, user_id, content, reply_to, created_at) VALUES (?, ?, ?, ?, ?)').run(
channel.lastInsertRowid,
user.lastInsertRowid,
'inline reply /uploads/inline-orphan.txt',
parent.lastInsertRowid,
'2019-01-01 00:00:00'
);
const importedOld = insertMessage.run(channel.lastInsertRowid, user.lastInsertRowid, 'imported old message', '2020-01-01 00:00:00');
const windowsAlias = '/uploads/folder' + String.fromCharCode(92) + '..' + String.fromCharCode(92) + 'windows-shared.txt';
const external = insertMessage.run(otherChannel.lastInsertRowid, user.lastInsertRowid, 'other /UPLOADS/%67lobal-shared.txt /uploads//slash-shared.txt /uploads/folder/../dot-shared.txt ' + windowsAlias, '2026-01-04 00:00:00');
db.prepare("INSERT INTO messages (channel_id, user_id, content, created_at, edited_at) VALUES (?, ?, 'encrypted payload', '2010-01-01 00:00:00', '2019-01-01 00:00:00')").run(dmChannel.lastInsertRowid, user.lastInsertRowid);
db.prepare("INSERT INTO messages (channel_id, user_id, content, created_at) VALUES (?, ?, 'unrelated encrypted payload', '2026-06-01 00:00:00')").run(dmChannel.lastInsertRowid, otherUser.lastInsertRowid);
const addReaction = db.prepare("INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, 'ok')");
const addPin = db.prepare('INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)');
for (const id of [parent.lastInsertRowid, threadReply.lastInsertRowid, inlineReply.lastInsertRowid]) {
addReaction.run(id, user.lastInsertRowid);
addPin.run(id, channel.lastInsertRowid, user.lastInsertRowid);
}
db.prepare('INSERT INTO custom_sounds (name, filename, uploaded_by) VALUES (?, ?, ?)').run('Protected', 'protected.mp3', user.lastInsertRowid);
const ownChannelUpload = db.prepare("INSERT INTO upload_ownership (rel_path, user_id, bytes, scope, created_at) VALUES (?, ?, ?, 'channel', '2020-01-01 00:00:00')");
for (const relPath of ['local-shared.txt', 'global-shared.txt', 'slash-shared.txt', 'dot-shared.txt', 'windows-shared.txt', 'escape-victim.txt', 'avatar-protected.txt', 'protected.mp3', 'orphan.txt', 'inline-orphan.txt']) {
ownChannelUpload.run(relPath, user.lastInsertRowid, 7);
}
db.prepare("INSERT INTO upload_ownership (rel_path, user_id, bytes, scope, created_at) VALUES (?, ?, ?, 'channel', '2018-01-01 00:00:00')").run('dm-edited.txt', user.lastInsertRowid, 7);
db.prepare("INSERT INTO upload_ownership (rel_path, user_id, bytes, scope) VALUES (?, ?, ?, 'dm')").run('private.txt', user.lastInsertRowid, 7);
const uploads = path.join(process.env.HAVEN_DATA_DIR, 'uploads');
fs.writeFileSync(path.join(uploads, 'local-shared.txt'), 'shared');
fs.writeFileSync(path.join(uploads, 'global-shared.txt'), 'global');
fs.writeFileSync(path.join(uploads, 'slash-shared.txt'), 'slash');
fs.writeFileSync(path.join(uploads, 'dot-shared.txt'), 'dot');
fs.writeFileSync(path.join(uploads, 'windows-shared.txt'), 'windows');
fs.writeFileSync(path.join(uploads, 'escape-victim.txt'), 'escape');
fs.writeFileSync(path.join(uploads, 'avatar-protected.txt'), 'avatar');
fs.writeFileSync(path.join(uploads, 'orphan.txt'), 'orphan');
fs.writeFileSync(path.join(uploads, 'inline-orphan.txt'), 'inline');
fs.writeFileSync(path.join(uploads, 'protected.mp3'), 'sound');
fs.writeFileSync(path.join(uploads, 'private.txt'), 'private');
fs.writeFileSync(path.join(uploads, 'dm-edited.txt'), 'dm edited');
const fixture = {
humanToken: jwt.sign({ id: Number(user.lastInsertRowid), username: 'owner', pwv: 1 }, process.env.JWT_SECRET),
survivorShared: Number(survivorShared.lastInsertRowid),
survivorPlain: Number(survivorPlain.lastInsertRowid),
parent: Number(parent.lastInsertRowid),
threadReply: Number(threadReply.lastInsertRowid),
inlineReply: Number(inlineReply.lastInsertRowid),
importedOld: Number(importedOld.lastInsertRowid),
external: Number(external.lastInsertRowid)
};
fs.writeFileSync(path.join(process.env.HAVEN_DATA_DIR, 'fixture.json'), JSON.stringify(fixture));
db.close();
`], { cwd: root, env, encoding: 'utf8' });
assert.equal(seed.status, 0, seed.stderr || seed.stdout);
const fixture = JSON.parse(await fs.promises.readFile(path.join(dataDir, 'fixture.json'), 'utf8'));
let logs = '';
const child = spawn(process.execPath, ['server.js'], {
cwd: root,
env,
stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout.on('data', chunk => { logs += chunk; });
child.stderr.on('data', chunk => { logs += chunk; });
let socket;
t.after(async () => {
socket?.terminate();
if (child.exitCode === null) child.kill('SIGTERM');
await Promise.race([
child.exitCode === null ? new Promise(resolve => child.once('exit', resolve)) : Promise.resolve(),
new Promise(resolve => setTimeout(resolve, 3000))
]);
if (child.exitCode === null) child.kill('SIGKILL');
await fs.promises.rm(dataDir, { recursive: true, force: true });
});
const baseUrl = `http://127.0.0.1:${port}`;
await waitForServer(`${baseUrl}/api/health`, child, () => logs);
const channelSocket = connectChannelSocket(baseUrl, fixture.humanToken, 'abcd1234');
socket = channelSocket.ws;
await channelSocket.ready;
const noPermission = await fetch(`${baseUrl}/api/webhooks/${noModToken}/messages?limit=1`, { method: 'DELETE' });
assert.equal(noPermission.status, 403);
assert.deepEqual(await noPermission.json(), { error: 'This bot does not have moderation permission' });
const missingBot = await fetch(`${baseUrl}/api/webhooks/${'x'.repeat(64)}/messages?limit=1`, { method: 'DELETE' });
assert.equal(missingBot.status, 404);
for (const query of ['', '?limit=0', '?limit=1.5', '?limit=101', '?limit=abc']) {
const response = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages${query}`, { method: 'DELETE' });
assert.equal(response.status, 400, query || 'missing limit');
assert.match((await response.json()).error, /between 1 and 100/);
}
const firstCheckpoint = channelSocket.checkpoint();
const parentEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === fixture.parent
);
const replyEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === fixture.threadReply
);
const inlineReplyEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === fixture.inlineReply
);
const firstDelete = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages?limit=1`, { method: 'DELETE' });
assert.equal(firstDelete.status, 200);
assert.deepEqual(await firstDelete.json(), { success: true, deleted: 3 });
assert.deepEqual(await Promise.all([parentEvent, replyEvent, inlineReplyEvent]), [
{ channelCode: 'abcd1234', messageId: fixture.parent },
{ channelCode: 'abcd1234', messageId: fixture.threadReply },
{ channelCode: 'abcd1234', messageId: fixture.inlineReply }
]);
await new Promise(resolve => setTimeout(resolve, 25));
assert.deepEqual(
channelSocket.eventsSince(firstCheckpoint, 'message-deleted').sort((a, b) => a.messageId - b.messageId),
[
{ channelCode: 'abcd1234', messageId: fixture.parent },
{ channelCode: 'abcd1234', messageId: fixture.threadReply },
{ channelCode: 'abcd1234', messageId: fixture.inlineReply }
]
);
const db = new Database(path.join(dataDir, 'haven.db'));
db.pragma('foreign_keys = ON');
const deletedIds = [fixture.parent, fixture.threadReply, fixture.inlineReply];
const placeholders = deletedIds.map(() => '?').join(',');
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE id IN (${placeholders})`).get(...deletedIds).count, 0);
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM reactions WHERE message_id IN (${placeholders})`).get(...deletedIds).count, 0);
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM pinned_messages WHERE message_id IN (${placeholders})`).get(...deletedIds).count, 0);
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE id IN (?, ?, ?)').get(fixture.survivorShared, fixture.survivorPlain, fixture.importedOld).count, 3);
const uploads = path.join(dataDir, 'uploads');
const deletedUploads = path.join(uploads, 'deleted-attachments');
assert.equal(fs.existsSync(path.join(uploads, 'local-shared.txt')), true);
assert.equal(fs.existsSync(path.join(deletedUploads, 'local-shared.txt')), false);
assert.equal(fs.existsSync(path.join(uploads, 'global-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'slash-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'dot-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'windows-shared.txt')), process.platform === 'win32');
assert.equal(fs.existsSync(path.join(uploads, 'escape-victim.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'avatar-protected.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'orphan.txt')), false);
assert.equal(fs.existsSync(path.join(deletedUploads, 'orphan.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'inline-orphan.txt')), false);
assert.equal(fs.existsSync(path.join(deletedUploads, 'inline-orphan.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'protected.mp3')), true);
assert.equal(fs.existsSync(path.join(uploads, 'private.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'dm-edited.txt')), true);
const secondCheckpoint = channelSocket.checkpoint();
const survivorSharedEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === fixture.survivorShared
);
const survivorPlainEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === fixture.survivorPlain
);
const secondDelete = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages?limit=2`, { method: 'DELETE' });
assert.equal(secondDelete.status, 200);
assert.deepEqual(await secondDelete.json(), { success: true, deleted: 2 });
await Promise.all([survivorSharedEvent, survivorPlainEvent]);
await new Promise(resolve => setTimeout(resolve, 25));
assert.deepEqual(
channelSocket.eventsSince(secondCheckpoint, 'message-deleted').sort((a, b) => a.messageId - b.messageId),
[
{ channelCode: 'abcd1234', messageId: fixture.survivorShared },
{ channelCode: 'abcd1234', messageId: fixture.survivorPlain }
]
);
assert.equal(fs.existsSync(path.join(uploads, 'local-shared.txt')), false);
assert.equal(fs.existsSync(path.join(deletedUploads, 'local-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'global-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'slash-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'dot-shared.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'windows-shared.txt')), process.platform === 'win32');
assert.equal(fs.existsSync(path.join(uploads, 'escape-victim.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'avatar-protected.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'protected.mp3')), true);
assert.equal(fs.existsSync(path.join(uploads, 'private.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'dm-edited.txt')), true);
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE channel_id = (SELECT id FROM channels WHERE code = ?)').get('abcd1234').count, 1);
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE id = ?').get(fixture.importedOld).count, 1);
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE id = ?').get(fixture.external).count, 1);
const importedEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === fixture.importedOld
);
const thirdDelete = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages?limit=1`, { method: 'DELETE' });
assert.equal(thirdDelete.status, 200);
assert.deepEqual(await thirdDelete.json(), { success: true, deleted: 1 });
await importedEvent;
const emptyDelete = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages?limit=1`, { method: 'DELETE' });
assert.equal(emptyDelete.status, 200);
assert.deepEqual(await emptyDelete.json(), { success: true, deleted: 0 });
const userId = db.prepare('SELECT id FROM users WHERE username = ?').get('owner').id;
const channelId = db.prepare('SELECT id FROM channels WHERE code = ?').get('abcd1234').id;
const otherChannelId = db.prepare('SELECT id FROM channels WHERE code = ?').get('abcd5678').id;
fs.writeFileSync(path.join(uploads, 'legacy.txt'), 'legacy');
const singleMessage = db.prepare(`
INSERT INTO messages (channel_id, user_id, content, created_at)
VALUES (?, ?, '/uploads/global-shared.txt /uploads/protected.mp3 /uploads/private.txt /uploads/legacy.txt', '2026-12-01 00:00:00')
`).run(channelId, userId);
const singleEvent = channelSocket.waitFor(
'message-deleted',
payload => payload?.messageId === Number(singleMessage.lastInsertRowid)
);
// A bot without moderation permission deletes only bot messages.
const refusedSingle = await fetch(
`${baseUrl}/api/webhooks/${noModToken}/messages/${singleMessage.lastInsertRowid}`,
{ method: 'DELETE' }
);
assert.equal(refusedSingle.status, 403);
assert.ok(db.prepare('SELECT 1 FROM messages WHERE id = ?').get(singleMessage.lastInsertRowid), 'the person\'s message stays');
const singleDelete = await fetch(
`${baseUrl}/api/webhooks/${modToken}/messages/${singleMessage.lastInsertRowid}`,
{ method: 'DELETE' }
);
assert.equal(singleDelete.status, 200);
assert.deepEqual(await singleDelete.json(), { success: true });
await singleEvent;
// Only files nothing else uses, uploaded to a channel, go with it: a custom
// sound, a DM file and a file with no recorded owner all stay.
assert.equal(fs.existsSync(path.join(uploads, 'protected.mp3')), true);
assert.equal(fs.existsSync(path.join(uploads, 'private.txt')), true);
assert.equal(fs.existsSync(path.join(uploads, 'legacy.txt')), true);
const crossParent = db.prepare(
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES (?, ?, 'cross parent', '2027-01-01 00:00:00')"
).run(channelId, userId);
const crossReply = db.prepare(
"INSERT INTO messages (channel_id, user_id, content, thread_id, created_at) VALUES (?, ?, 'cross reply', ?, '2027-01-01 00:01:00')"
).run(otherChannelId, userId, crossParent.lastInsertRowid);
const crossCheckpoint = channelSocket.checkpoint();
const crossDelete = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages?limit=1`, { method: 'DELETE' });
assert.equal(crossDelete.status, 409);
assert.deepEqual(await crossDelete.json(), { error: 'Related replies exist in another channel' });
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE id IN (?, ?)').get(crossParent.lastInsertRowid, crossReply.lastInsertRowid).count, 2);
await new Promise(resolve => setTimeout(resolve, 25));
assert.deepEqual(channelSocket.eventsSince(crossCheckpoint, 'message-deleted'), []);
db.prepare('DELETE FROM messages WHERE id = ?').run(crossReply.lastInsertRowid);
db.prepare('DELETE FROM messages WHERE id = ?').run(crossParent.lastInsertRowid);
const rollbackFirst = db.prepare(
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES (?, ?, '/uploads/rollback.txt', '2027-02-01 00:00:00')"
).run(channelId, userId);
const rollbackSecond = db.prepare(
"INSERT INTO messages (channel_id, user_id, content, created_at) VALUES (?, ?, '/uploads/rollback.txt', '2027-02-02 00:00:00')"
).run(channelId, userId);
for (const id of [rollbackFirst.lastInsertRowid, rollbackSecond.lastInsertRowid]) {
db.prepare("INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, 'rollback')").run(id, userId);
db.prepare('INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)').run(id, channelId, userId);
}
fs.writeFileSync(path.join(uploads, 'rollback.txt'), 'rollback');
db.exec(`
CREATE TRIGGER fail_webhook_bulk_delete
BEFORE DELETE ON messages
WHEN OLD.id = ${Number(rollbackFirst.lastInsertRowid)}
BEGIN
SELECT RAISE(ABORT, 'blocked by test');
END
`);
const rollbackCheckpoint = channelSocket.checkpoint();
const rollbackDelete = await fetch(`${baseUrl}/api/webhooks/${modToken}/messages?limit=2`, { method: 'DELETE' });
assert.equal(rollbackDelete.status, 500);
assert.deepEqual(await rollbackDelete.json(), { error: 'Failed to delete messages' });
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE id IN (?, ?)').get(rollbackFirst.lastInsertRowid, rollbackSecond.lastInsertRowid).count, 2);
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM reactions WHERE message_id IN (?, ?)').get(rollbackFirst.lastInsertRowid, rollbackSecond.lastInsertRowid).count, 2);
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM pinned_messages WHERE message_id IN (?, ?)').get(rollbackFirst.lastInsertRowid, rollbackSecond.lastInsertRowid).count, 2);
assert.equal(fs.existsSync(path.join(uploads, 'rollback.txt')), true);
assert.equal(fs.existsSync(path.join(deletedUploads, 'rollback.txt')), false);
await new Promise(resolve => setTimeout(resolve, 25));
assert.deepEqual(channelSocket.eventsSince(rollbackCheckpoint, 'message-deleted'), []);
db.exec('DROP TRIGGER fail_webhook_bulk_delete');
db.close();
});