Add /ban-role moderation command and improve module loading
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { Collection, MessageFlags, PermissionFlagsBits } = require('discord.js');
|
||||
const records = require('../services/moderationRecords');
|
||||
const permissions = require('../services/permissions');
|
||||
|
||||
const makeMember = (id, { position = 1, hasRole = true, bannable = true, canBan = true } = {}) => ({
|
||||
id,
|
||||
bannable,
|
||||
permissions: { has: permission => permission === PermissionFlagsBits.BanMembers && canBan },
|
||||
roles: {
|
||||
cache: new Collection(hasRole ? [['role', {}]] : []),
|
||||
highest: { position, comparePositionTo(other) { return this.position - other.position; } },
|
||||
},
|
||||
});
|
||||
|
||||
const createHarness = (t, options = {}) => {
|
||||
const members = options.members || [makeMember('target')];
|
||||
const moderator = makeMember('moderator', { position: 10, canBan: options.moderatorCanBan !== false });
|
||||
const bot = makeMember('bot', { position: 20, canBan: options.botCanBan !== false });
|
||||
const savedRecords = [];
|
||||
const bans = [];
|
||||
const banAttempts = [];
|
||||
const edits = [];
|
||||
const replies = [];
|
||||
const listCalls = [];
|
||||
const fetchCalls = [];
|
||||
let filter;
|
||||
|
||||
t.mock.method(console, 'error', () => {});
|
||||
t.mock.method(records, 'recordModerationAction', async (record) => {
|
||||
savedRecords.push(record);
|
||||
if (options.logFailure) throw new Error('Database unavailable');
|
||||
});
|
||||
t.mock.method(permissions, 'isCommandAllowed', async () => options.allowed !== false);
|
||||
delete require.cache[require.resolve('../commands/moderation/ban_role')];
|
||||
const command = require('../commands/moderation/ban_role');
|
||||
|
||||
const confirmation = {
|
||||
customId: `ban-role:${options.choice || 'confirm'}:interaction`,
|
||||
update: async (payload) => {
|
||||
edits.push(payload);
|
||||
if (options.onConfirm) options.onConfirm({ members, moderator, bot });
|
||||
},
|
||||
editReply: async (payload) => { edits.push(payload); },
|
||||
};
|
||||
const message = {
|
||||
awaitMessageComponent: async (collectorOptions) => {
|
||||
assert.equal(bans.length, 0, 'Bans must wait for confirmation');
|
||||
assert.equal(collectorOptions.time, 60_000);
|
||||
filter = collectorOptions.filter;
|
||||
if (options.onWait) await options.onWait({ command, interaction });
|
||||
if (options.timeout) throw new Error('Collector timed out');
|
||||
return confirmation;
|
||||
},
|
||||
};
|
||||
const guild = {
|
||||
id: 'guild',
|
||||
ownerId: options.ownerId || 'owner',
|
||||
roles: { fetch: async () => {} },
|
||||
members: {
|
||||
fetchMe: async () => bot,
|
||||
fetch: async (fetchOptions) => {
|
||||
assert.equal(fetchOptions.force, true);
|
||||
fetchCalls.push(fetchOptions.user);
|
||||
if (fetchOptions.user === moderator.id) return moderator;
|
||||
const failure = options.fetchFailures?.[fetchOptions.user];
|
||||
if (failure) throw failure;
|
||||
return members.find(member => member.id === fetchOptions.user);
|
||||
},
|
||||
list: async (listOptions) => {
|
||||
listCalls.push(listOptions);
|
||||
if (options.listFailureAt === listCalls.length) throw new Error('Listing failed');
|
||||
const page = options.pages ? options.pages[listCalls.length - 1] : members;
|
||||
return new Collection(page.map(member => [member.id, member]));
|
||||
},
|
||||
},
|
||||
bans: {
|
||||
create: async (id, banOptions) => {
|
||||
banAttempts.push(id);
|
||||
if (options.banFailures?.includes(id)) throw new Error('Ban rejected');
|
||||
bans.push({ id, ...banOptions });
|
||||
if (options.onBan) options.onBan({ id, moderator, bot });
|
||||
},
|
||||
},
|
||||
};
|
||||
const interaction = {
|
||||
id: 'interaction',
|
||||
guild: options.dm ? null : guild,
|
||||
guildId: guild.id,
|
||||
user: { id: moderator.id },
|
||||
client: { user: { id: bot.id } },
|
||||
options: {
|
||||
getRole: () => ({ id: options.roleId || 'role' }),
|
||||
getString: () => options.reason ?? 'Raid accounts',
|
||||
},
|
||||
reply: async (payload) => { replies.push(payload); },
|
||||
deferReply: async (payload) => { assert.equal(payload.flags, MessageFlags.Ephemeral); },
|
||||
editReply: async (payload) => { edits.push(payload); return message; },
|
||||
};
|
||||
return {
|
||||
command, interaction, bans, banAttempts, savedRecords, edits, replies, listCalls, fetchCalls,
|
||||
run: () => command.execute(interaction),
|
||||
get filter() { return filter; },
|
||||
get result() { return edits.at(-1)?.content || replies.at(-1)?.content; },
|
||||
};
|
||||
};
|
||||
|
||||
test('command registration requires a role, reason, and Ban Members permission', (t) => {
|
||||
const { command } = createHarness(t);
|
||||
const data = command.data.toJSON();
|
||||
assert.equal(data.name, 'ban-role');
|
||||
assert.equal(data.dm_permission, false);
|
||||
assert.equal(data.default_member_permissions, PermissionFlagsBits.BanMembers.toString());
|
||||
assert.deepEqual(data.options.map(option => [option.name, option.required]), [['role', true], ['reason', true]]);
|
||||
});
|
||||
|
||||
for (const [description, options, expected] of [
|
||||
['direct messages', { dm: true }, /inside a server/],
|
||||
['everyone', { roleId: 'guild' }, /@everyone/],
|
||||
['blank reasons', { reason: ' ' }, /provide a reason/],
|
||||
['moderators without Ban Members', { moderatorCanBan: false }, /You need the Ban Members/],
|
||||
['bots without Ban Members', { botCanBan: false }, /I need the Ban Members/],
|
||||
]) {
|
||||
test(`rejects ${description} without listing or banning members`, async (t) => {
|
||||
const harness = createHarness(t, options);
|
||||
await harness.run();
|
||||
assert.match(harness.result, expected);
|
||||
assert.equal(harness.listCalls.length, 0);
|
||||
assert.equal(harness.bans.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('bans only eligible role members and records successful bans', async (t) => {
|
||||
const harness = createHarness(t, { members: [
|
||||
makeMember('eligible'),
|
||||
makeMember('other-role', { hasRole: false }),
|
||||
makeMember('moderator'),
|
||||
makeMember('bot'),
|
||||
makeMember('owner'),
|
||||
makeMember('peer', { position: 10 }),
|
||||
makeMember('higher', { position: 11 }),
|
||||
makeMember('unbannable', { bannable: false }),
|
||||
] });
|
||||
await harness.run();
|
||||
assert.deepEqual(harness.bans.map(ban => ban.id), ['eligible']);
|
||||
assert.equal(harness.bans[0].deleteMessageSeconds, 0);
|
||||
assert.match(harness.bans[0].reason, /moderator: Raid accounts/);
|
||||
assert.deepEqual(harness.savedRecords[0], {
|
||||
guildId: 'guild', userId: 'eligible', action: 'ban', moderatorId: 'moderator', reason: 'Raid accounts',
|
||||
metadata: { command: 'ban-role', roleId: 'role' },
|
||||
stateUpdates: { isBanned: true, bannedUntil: null },
|
||||
});
|
||||
assert.match(harness.result, /Banned: 1\. Skipped: 6\. Failed: 0/);
|
||||
assert.equal(harness.filter({ user: { id: 'stranger' }, customId: 'ban-role:confirm:interaction' }), false);
|
||||
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'different-action' }), false);
|
||||
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'ban-role:confirm:interaction' }), true);
|
||||
});
|
||||
|
||||
test('server owner bypasses only the moderator hierarchy', async (t) => {
|
||||
const harness = createHarness(t, { ownerId: 'moderator', members: [
|
||||
makeMember('above-owner', { position: 11 }),
|
||||
makeMember('above-bot', { position: 21, bannable: false }),
|
||||
] });
|
||||
await harness.run();
|
||||
assert.deepEqual(harness.bans.map(ban => ban.id), ['above-owner']);
|
||||
});
|
||||
|
||||
for (const [description, options, expected] of [
|
||||
['cancellation', { choice: 'cancel' }, /Cancelled/],
|
||||
['timeout', { timeout: true }, /expired/],
|
||||
['empty roles', { members: [] }, /No eligible members/],
|
||||
['protected members only', { members: [makeMember('owner')] }, /Skipped: 1/],
|
||||
['revoked dashboard permission', { allowed: false }, /no longer have permission/],
|
||||
['revoked Discord permission', { onConfirm: ({ moderator }) => { moderator.permissions.has = () => false; } }, /You need the Ban Members/],
|
||||
]) {
|
||||
test(`${description} produces no bans`, async (t) => {
|
||||
const harness = createHarness(t, options);
|
||||
await harness.run();
|
||||
assert.equal(harness.bans.length, 0);
|
||||
assert.equal(harness.savedRecords.length, 0);
|
||||
assert.match(harness.result, expected);
|
||||
assert.equal(harness.edits.at(-1)?.components?.length || 0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('discovers matching members beyond the first 1000 members', async (t) => {
|
||||
const firstPage = Array.from({ length: 1000 }, (_, id) => makeMember(String(id), { hasRole: false }));
|
||||
const target = makeMember('1000');
|
||||
const harness = createHarness(t, { members: [target], pages: [firstPage, [target]] });
|
||||
await harness.run();
|
||||
assert.deepEqual(harness.listCalls, [
|
||||
{ limit: 1000, after: undefined, cache: false },
|
||||
{ limit: 1000, after: '999', cache: false },
|
||||
]);
|
||||
assert.deepEqual(harness.bans.map(ban => ban.id), ['1000']);
|
||||
});
|
||||
|
||||
test('incomplete member discovery never offers confirmation or bans a partial list', async (t) => {
|
||||
const page = Array.from({ length: 1000 }, (_, id) => makeMember(String(id)));
|
||||
const harness = createHarness(t, { pages: [page], listFailureAt: 2 });
|
||||
await harness.run();
|
||||
assert.equal(harness.bans.length, 0);
|
||||
assert.equal(harness.filter, undefined);
|
||||
assert.match(harness.result, /Could not load all server members/);
|
||||
});
|
||||
|
||||
test('rechecks role membership and hierarchy and excludes members added after the preview', async (t) => {
|
||||
const harness = createHarness(t, {
|
||||
members: [makeMember('removed'), makeMember('promoted'), makeMember('left'), makeMember('remaining')],
|
||||
fetchFailures: { left: { code: 10007 } },
|
||||
onConfirm: ({ members }) => {
|
||||
members[0].roles.cache.clear();
|
||||
members[1].roles.highest.position = 15;
|
||||
members.push(makeMember('new'));
|
||||
},
|
||||
});
|
||||
await harness.run();
|
||||
assert.deepEqual(harness.bans.map(ban => ban.id), ['remaining']);
|
||||
assert.match(harness.result, /Banned: 1\. Skipped: 3\. Failed: 0/);
|
||||
});
|
||||
|
||||
test('continues after fetch, ban, and record failures while reporting correct totals', async (t) => {
|
||||
const harness = createHarness(t, {
|
||||
members: [makeMember('unavailable'), makeMember('denied'), makeMember('success'), makeMember('success2')],
|
||||
fetchFailures: { unavailable: new Error('Network failed') },
|
||||
banFailures: ['denied'],
|
||||
logFailure: true,
|
||||
});
|
||||
await harness.run();
|
||||
assert.deepEqual(harness.banAttempts, ['denied', 'success', 'success2']);
|
||||
assert.deepEqual(harness.savedRecords.map(record => record.userId), ['success', 'success2']);
|
||||
assert.match(harness.result, /Banned: 2\. Skipped: 0\. Failed: 2/);
|
||||
assert.match(harness.result, /could not be saved for 2 successful bans/);
|
||||
});
|
||||
|
||||
test('stops and preserves partial results if moderator permissions change mid-batch', async (t) => {
|
||||
const harness = createHarness(t, {
|
||||
members: [makeMember('first'), makeMember('second')],
|
||||
onBan: ({ moderator }) => { moderator.permissions.has = () => false; },
|
||||
});
|
||||
await harness.run();
|
||||
assert.deepEqual(harness.bans.map(ban => ban.id), ['first']);
|
||||
assert.match(harness.result, /Role ban stopped/);
|
||||
assert.match(harness.result, /Banned: 1/);
|
||||
});
|
||||
|
||||
test('prevents overlapping role bans and releases the server after cancellation', async (t) => {
|
||||
let overlapChecks = 0;
|
||||
const harness = createHarness(t, {
|
||||
choice: 'cancel',
|
||||
onWait: async ({ command, interaction }) => {
|
||||
overlapChecks++;
|
||||
await command.execute({
|
||||
...interaction,
|
||||
reply: async (payload) => assert.match(payload.content, /already being previewed or processed/),
|
||||
});
|
||||
},
|
||||
});
|
||||
await harness.run();
|
||||
await harness.run();
|
||||
assert.equal(overlapChecks, 2);
|
||||
assert.equal(harness.bans.length, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user