diff --git a/src/api/routes/health.js b/src/api/routes/health.js index b83128fd..1b00391b 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -105,8 +105,9 @@ router.get('/', async (req, res) => { const pool = getRestartPool(); if (pool) { const rows = await getRestarts(pool, 20); - body.restarts = rows.map(r => ({ - timestamp: r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), + body.restarts = rows.map((r) => ({ + timestamp: + r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), reason: r.reason || 'unknown', version: r.version ?? null, uptimeBefore: r.uptime_seconds ?? null, diff --git a/src/api/ws/logStream.js b/src/api/ws/logStream.js index 966c5b14..0ed0d272 100644 --- a/src/api/ws/logStream.js +++ b/src/api/ws/logStream.js @@ -182,9 +182,7 @@ function validateTicket(ticket, secret) { if (!Number.isFinite(expiryNum) || expiryNum <= Date.now()) return false; // Re-derive HMAC and compare with timing-safe equality - const expected = createHmac('sha256', secret) - .update(`${nonce}.${expiry}`) - .digest('hex'); + const expected = createHmac('sha256', secret).update(`${nonce}.${expiry}`).digest('hex'); try { return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex')); diff --git a/src/db.js b/src/db.js index dccbd9ac..8b965afe 100644 --- a/src/db.js +++ b/src/db.js @@ -3,10 +3,10 @@ * PostgreSQL connection pool and migration runner */ -import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import pg from 'pg'; +import { fileURLToPath } from 'node:url'; import { runner } from 'node-pg-migrate'; +import pg from 'pg'; import { info, error as logError } from './logger.js'; const { Pool } = pg; diff --git a/src/index.js b/src/index.js index 7222f413..c3f8fd63 100644 --- a/src/index.js +++ b/src/index.js @@ -26,7 +26,15 @@ import { setInitialTransport, } from './config-listeners.js'; import { closeDb, getPool, initDb } from './db.js'; -import { addPostgresTransport, addWebSocketTransport, removeWebSocketTransport, debug, error, info, warn } from './logger.js'; +import { + addPostgresTransport, + addWebSocketTransport, + debug, + error, + info, + removeWebSocketTransport, + warn, +} from './logger.js'; import { getConversationHistory, initConversationHistory, @@ -221,7 +229,12 @@ client.on('interactionCreate', async (interaction) => { await command.execute(interaction); info('Command executed', { command: commandName, user: interaction.user.tag }); } catch (err) { - error('Command error', { command: commandName, error: err.message, stack: err.stack, source: 'slash_command' }); + error('Command error', { + command: commandName, + error: err.message, + stack: err.stack, + source: 'slash_command', + }); const errorMessage = { content: '❌ An error occurred while executing this command.', @@ -435,13 +448,17 @@ async function startup() { await client.login(token); // Set Sentry context now that we know the bot identity (no-op if disabled) - import('./sentry.js').then(({ Sentry, sentryEnabled }) => { - if (sentryEnabled) { - Sentry.setTag('bot.username', client.user?.tag || 'unknown'); - Sentry.setTag('bot.version', BOT_VERSION); - info('Sentry error monitoring enabled', { environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production' }); - } - }).catch(() => {}); + import('./sentry.js') + .then(({ Sentry, sentryEnabled }) => { + if (sentryEnabled) { + Sentry.setTag('bot.username', client.user?.tag || 'unknown'); + Sentry.setTag('bot.version', BOT_VERSION); + info('Sentry error monitoring enabled', { + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production', + }); + } + }) + .catch(() => {}); // Start REST API server with WebSocket log streaming (non-fatal — bot continues without it) { diff --git a/src/logger.js b/src/logger.js index dd3407a7..30cca2ee 100644 --- a/src/logger.js +++ b/src/logger.js @@ -13,8 +13,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; -import { PostgresTransport } from './transports/postgres.js'; import { sentryEnabled } from './sentry.js'; +import { PostgresTransport } from './transports/postgres.js'; import { SentryTransport } from './transports/sentry.js'; import { WebSocketTransport } from './transports/websocket.js'; diff --git a/src/modules/events.js b/src/modules/events.js index e3860608..c5f69bb6 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -11,6 +11,8 @@ import { getUserFriendlyMessage } from '../utils/errors.js'; // safe wrapper applies identically to either target type. import { safeReply } from '../utils/safeSend.js'; import { getConfig } from './config.js'; +import { checkLinks } from './linkFilter.js'; +import { checkRateLimit } from './rateLimit.js'; import { isSpam, sendSpamAlert } from './spam.js'; import { accumulateMessage, evaluateNow } from './triage.js'; import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; @@ -93,6 +95,32 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { // Resolve per-guild config so feature gates respect guild overrides const guildConfig = getConfig(message.guild.id); + // Rate limit + link filter — both gated on moderation.enabled. + // Each check is isolated so a failure in one doesn't prevent the other from running. + if (guildConfig.moderation?.enabled) { + try { + const { limited } = await checkRateLimit(message, guildConfig); + if (limited) return; + } catch (rlErr) { + logError('Rate limit check failed', { + channelId: message.channel.id, + userId: message.author.id, + error: rlErr?.message, + }); + } + + try { + const { blocked } = await checkLinks(message, guildConfig); + if (blocked) return; + } catch (lfErr) { + logError('Link filter check failed', { + channelId: message.channel.id, + userId: message.author.id, + error: lfErr?.message, + }); + } + } + // Spam detection if (guildConfig.moderation?.enabled && isSpam(message.content)) { warn('Spam detected', { userId: message.author.id, contentPreview: '[redacted]' }); diff --git a/src/modules/linkFilter.js b/src/modules/linkFilter.js new file mode 100644 index 00000000..d75bc515 --- /dev/null +++ b/src/modules/linkFilter.js @@ -0,0 +1,176 @@ +/** + * Link Filter Module + * Extracts URLs from messages and checks against a configurable domain blocklist. + * Also detects phishing TLD patterns (.xyz with suspicious keywords). + */ + +import { EmbedBuilder } from 'discord.js'; +import { warn } from '../logger.js'; +import { isExempt } from '../utils/modExempt.js'; +import { safeSend } from '../utils/safeSend.js'; +import { sanitizeMentions } from '../utils/sanitizeMentions.js'; + +/** + * Regex to extract URLs from message content. + * Matches http/https URLs and bare domain.tld patterns. + */ +const URL_REGEX = + /https?:\/\/(?:www\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z]{2,})+)(\/[^\s]*)?|(?:^|\s)(?:www\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z]{2,})+)(\/[^\s]*)?/gi; + +/** + * Phishing TLD patterns: .xyz links whose path/subdomain contains scam keywords. + * Catches "discord-nitro-free.xyz", "free-nitro.xyz/claim", etc. + */ +const PHISHING_PATTERNS = [ + // .xyz domains with suspicious keywords anywhere in the URL + /(?:discord|nitro|free|gift|giveaway|steam|crypto|nft|airdrop)[a-z0-9\-_.]*\.xyz(?:\/[^\s]*)?/i, + // Any .xyz URL that contains those keywords in the path + /[a-z0-9\-_.]+\.xyz\/[^\s]*(?:discord|nitro|free|gift|steam|crypto)[^\s]*/i, + // Common phishing subdomains regardless of TLD + /(?:discord-nitro|discordnitro|free-nitro|steamgift)\.[a-z]{2,}(?:\/[^\s]*)?/i, +]; + +/** + * Normalize a domain entry from the blocklist. + * Lowercases the value and strips a leading "www." so that blocklist entries + * are comparable to the already-normalized hostnames extracted by extractUrls(). + * + * @param {string} domain + * @returns {string} + */ +function normalizeBlockedDomain(domain) { + return domain.toLowerCase().replace(/^www\./, ''); +} + +/** + * Extract all hostnames/domains from a message string. + * @param {string} content + * @returns {{ hostname: string, fullUrl: string }[]} + */ +export function extractUrls(content) { + const results = []; + const seen = new Set(); + let match; + const regex = new RegExp(URL_REGEX.source, URL_REGEX.flags); + + for (match = regex.exec(content); match; match = regex.exec(content)) { + // Group 1: hostname from http(s):// URL, Group 3: bare domain + const hostname = (match[1] || match[3] || '').toLowerCase().replace(/^www\./, ''); + const fullUrl = match[0].trim(); + + if (hostname && !seen.has(hostname)) { + seen.add(hostname); + results.push({ hostname, fullUrl }); + } + } + + return results; +} + +/** + * Check whether the content contains any phishing TLD patterns. + * @param {string} content + * @returns {string|null} matched pattern string or null + */ +export function matchPhishingPattern(content) { + for (const pattern of PHISHING_PATTERNS) { + const m = content.match(pattern); + if (m) return m[0]; + } + return null; +} + +/** + * Alert the mod channel about a blocked link. + * @param {import('discord.js').Message} message + * @param {Object} config + * @param {string} matchedDomain + * @param {string} reason - 'blocklist' | 'phishing' + */ +async function alertModChannel(message, config, matchedDomain, reason) { + const alertChannelId = config.moderation?.alertChannelId; + if (!alertChannelId) return; + + const alertChannel = await message.client.channels.fetch(alertChannelId).catch(() => null); + if (!alertChannel) return; + + const embed = new EmbedBuilder() + .setColor(0xed4245) + .setTitle( + `🔗 Suspicious Link ${reason === 'phishing' ? '(Phishing Pattern)' : '(Blocklisted Domain)'} Detected`, + ) + .addFields( + { + name: 'User', + value: `<@${message.author.id}> (${sanitizeMentions(message.author.tag)})`, + inline: true, + }, + { name: 'Channel', value: `<#${message.channel.id}>`, inline: true }, + { name: 'Matched', value: `\`${matchedDomain}\``, inline: true }, + { name: 'Content', value: sanitizeMentions(message.content.slice(0, 1000)) || '*empty*' }, + ) + .setTimestamp(); + + await safeSend(alertChannel, { embeds: [embed] }).catch(() => {}); +} + +/** + * Check whether a message contains blocked or suspicious links. + * Deletes the message and alerts the mod channel if a match is found. + * + * @param {import('discord.js').Message} message - Discord message object + * @param {Object} config - Bot config (merged guild config) + * @returns {Promise<{ blocked: boolean, domain?: string }>} + */ +export async function checkLinks(message, config) { + const lfConfig = config.moderation?.linkFilter ?? {}; + + if (!lfConfig.enabled) return { blocked: false }; + if (isExempt(message, config)) return { blocked: false }; + + const content = message.content; + if (!content) return { blocked: false }; + + // 1. Check phishing patterns first (fast regex, no list lookup needed) + const phishingMatch = matchPhishingPattern(content); + if (phishingMatch) { + warn('Link filter: phishing pattern detected', { + userId: message.author.id, + channelId: message.channel.id, + match: phishingMatch, + }); + await message.delete().catch(() => {}); + await alertModChannel(message, config, phishingMatch, 'phishing'); + return { blocked: true, domain: phishingMatch }; + } + + // 2. Check extracted URLs against the configurable domain blocklist. + // Normalize each blocklist entry (lowercase, strip www.) so that + // mixed-case or www-prefixed config entries match correctly. + const rawBlockedDomains = lfConfig.blockedDomains ?? []; + if (rawBlockedDomains.length === 0) return { blocked: false }; + + const blockedDomains = rawBlockedDomains.map(normalizeBlockedDomain); + + const urls = extractUrls(content); + for (const { hostname, fullUrl } of urls) { + // Exact match or subdomain match (e.g. "evil.com" also catches "sub.evil.com") + const matched = blockedDomains.find( + (blocked) => hostname === blocked || hostname.endsWith(`.${blocked}`), + ); + + if (matched) { + warn('Link filter: blocked domain detected', { + userId: message.author.id, + channelId: message.channel.id, + hostname, + blockedRule: matched, + }); + await message.delete().catch(() => {}); + await alertModChannel(message, config, hostname || fullUrl, 'blocklist'); + return { blocked: true, domain: matched }; + } + } + + return { blocked: false }; +} diff --git a/src/modules/rateLimit.js b/src/modules/rateLimit.js new file mode 100644 index 00000000..6ad41ffe --- /dev/null +++ b/src/modules/rateLimit.js @@ -0,0 +1,225 @@ +/** + * Rate Limiting Module + * Tracks messages per user per channel with a sliding window. + * Actions on trigger: delete excess messages, warn user, temp-mute on repeat. + */ + +import { EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { info, warn } from '../logger.js'; +import { isExempt } from '../utils/modExempt.js'; +import { safeReply, safeSend } from '../utils/safeSend.js'; +import { sanitizeMentions } from '../utils/sanitizeMentions.js'; + +/** Maximum number of (userId:channelId) entries to track simultaneously. */ +let _maxTrackedUsers = 10_000; + +/** + * Override the memory cap. **For tests only.** + * Call clearRateLimitState() after to reset tracking. + * @param {number} n + */ +export function setMaxTrackedUsers(n) { + _maxTrackedUsers = n; +} + +/** + * Per-user-per-channel sliding window state. + * Key: `${userId}:${channelId}` + * Value: { timestamps: number[], triggerCount: number, triggerWindowStart: number } + * @type {Map} + */ +const windowMap = new Map(); + +/** + * Evict the oldest `count` entries when the cap is reached. + * @param {number} count + */ +function evictOldest(count = 1) { + const iter = windowMap.keys(); + for (let i = 0; i < count; i++) { + const next = iter.next(); + if (next.done) break; + windowMap.delete(next.value); + } +} + +/** + * Send a temp-mute (timeout) to a repeat offender and alert the mod channel. + * @param {import('discord.js').Message} message + * @param {Object} config + * @param {number} muteDurationMs + */ +async function handleRepeatOffender(message, config, muteDurationMs) { + const member = message.member; + if (!member) return; + + const rlConfig = config.moderation?.rateLimit ?? {}; + const muteThreshold = rlConfig.muteAfterTriggers ?? 3; + const muteWindowSeconds = rlConfig.muteWindowSeconds ?? 300; + + // Apply timeout — use PermissionFlagsBits constant, not a string + if (!member.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) { + warn('Rate limit: bot lacks MODERATE_MEMBERS permission', { guildId: message.guild.id }); + return; + } + try { + await member.timeout(muteDurationMs, 'Rate limit: repeated violations'); + info('Rate limit temp-mute applied', { + userId: message.author.id, + guildId: message.guild.id, + durationMs: muteDurationMs, + }); + } catch (err) { + warn('Rate limit: failed to apply timeout', { userId: message.author.id, error: err.message }); + } + + // Alert mod channel + const alertChannelId = config.moderation?.alertChannelId; + if (!alertChannelId) return; + + const alertChannel = await message.client.channels.fetch(alertChannelId).catch(() => null); + if (!alertChannel) return; + + const muteWindowMinutes = Math.round(muteWindowSeconds / 60); + const reasonText = + `Repeated rate limit violations ` + + `(${muteThreshold} triggers in ${muteWindowMinutes} minute${muteWindowMinutes === 1 ? '' : 's'})`; + + const embed = new EmbedBuilder() + .setColor(0xe67e22) + .setTitle('⏱️ Rate Limit: Temp-Mute Applied') + .addFields( + { + name: 'User', + value: `<@${message.author.id}> (${sanitizeMentions(message.author.tag)})`, + inline: true, + }, + { name: 'Channel', value: `<#${message.channel.id}>`, inline: true }, + { name: 'Duration', value: `${Math.round(muteDurationMs / 60000)} minute(s)`, inline: true }, + { name: 'Reason', value: reasonText }, + ) + .setTimestamp(); + + await safeSend(alertChannel, { embeds: [embed] }).catch(() => {}); +} + +/** + * Send a rate-limit warning to the offending user in-channel. + * Uses safeReply to enforce allowedMentions and sanitization. + * @param {import('discord.js').Message} message + * @param {number} maxMessages + * @param {number} windowSeconds + */ +async function warnUser(message, maxMessages, windowSeconds) { + const reply = await safeReply( + message, + `⚠️ <@${message.author.id}>, you're sending messages too fast! ` + + `Limit: ${maxMessages} messages per ${windowSeconds} seconds.`, + ).catch(() => null); + + // Auto-delete the warning after 10 seconds + if (reply) { + setTimeout(() => reply.delete().catch(() => {}), 10_000); + } +} + +/** + * Check whether a message triggers the rate limit. + * Side effects on trigger: deletes excess message, warns user, may temp-mute. + * + * @param {import('discord.js').Message} message - Discord message object + * @param {Object} config - Bot config (merged guild config) + * @returns {Promise<{ limited: boolean, reason?: string }>} + */ +export async function checkRateLimit(message, config) { + const rlConfig = config.moderation?.rateLimit ?? {}; + + if (!rlConfig.enabled) return { limited: false }; + if (isExempt(message, config)) return { limited: false }; + + const maxMessages = rlConfig.maxMessages ?? 10; + const windowSeconds = rlConfig.windowSeconds ?? 10; + const windowMs = windowSeconds * 1000; + + // Temp-mute config + const muteThreshold = rlConfig.muteAfterTriggers ?? 3; + const muteWindowSeconds = rlConfig.muteWindowSeconds ?? 300; // 5 minutes + const muteDurationMs = (rlConfig.muteDurationSeconds ?? 300) * 1000; // 5 minutes + + const key = `${message.author.id}:${message.channel.id}`; + const now = Date.now(); + + // Cap tracked users to avoid memory blowout + if (!windowMap.has(key) && windowMap.size >= _maxTrackedUsers) { + evictOldest(Math.ceil(_maxTrackedUsers * 0.1)); // evict 10% + } + + let entry = windowMap.get(key); + if (!entry) { + entry = { timestamps: [], triggerCount: 0, triggerWindowStart: now }; + windowMap.set(key, entry); + } + + // Slide the window: drop timestamps older than windowMs + const cutoff = now - windowMs; + entry.timestamps = entry.timestamps.filter((t) => t >= cutoff); + entry.timestamps.push(now); + + if (entry.timestamps.length <= maxMessages) { + return { limited: false }; + } + + // --- Rate limited --- + const reason = `Exceeded ${maxMessages} messages in ${windowSeconds}s`; + warn('Rate limit triggered', { + userId: message.author.id, + channelId: message.channel.id, + count: entry.timestamps.length, + max: maxMessages, + }); + + // Delete the excess message + await message.delete().catch(() => {}); + + // Track trigger count for mute escalation (sliding window) + const muteWindowMs = muteWindowSeconds * 1000; + if (now - entry.triggerWindowStart > muteWindowMs) { + // Reset trigger window + entry.triggerCount = 1; + entry.triggerWindowStart = now; + } else { + entry.triggerCount += 1; + } + + if (entry.triggerCount >= muteThreshold) { + // Reset counter so they don't get re-muted every single message + entry.triggerCount = 0; + entry.triggerWindowStart = now; + + await handleRepeatOffender(message, config, muteDurationMs); + return { limited: true, reason: `${reason} (temp-muted: repeat offender)` }; + } + + // Warn the user on first trigger + if (entry.triggerCount === 1) { + await warnUser(message, maxMessages, windowSeconds); + } + + return { limited: true, reason }; +} + +/** + * Clear all rate limit state. Primarily for testing. + */ +export function clearRateLimitState() { + windowMap.clear(); + _maxTrackedUsers = 10_000; +} + +/** + * Return current tracked user count. For monitoring/tests. + * @returns {number} + */ +export function getTrackedCount() { + return windowMap.size; +} diff --git a/src/modules/triage.js b/src/modules/triage.js index 26b1d345..46992dee 100644 --- a/src/modules/triage.js +++ b/src/modules/triage.js @@ -189,7 +189,10 @@ async function runResponder( try { await safeSend(ch, '\uD83D\uDD0D Searching the web for that \u2014 one moment...'); } catch (notifyErr) { - warn('Failed to send WebSearch notification', { channelId, error: notifyErr?.message }); + warn('Failed to send WebSearch notification', { + channelId, + error: notifyErr?.message, + }); } } } diff --git a/src/transports/sentry.js b/src/transports/sentry.js index 764cc18c..a4b0669c 100644 --- a/src/transports/sentry.js +++ b/src/transports/sentry.js @@ -44,7 +44,10 @@ export class SentryTransport extends Transport { const tags = {}; const extra = {}; for (const [key, value] of Object.entries(meta)) { - if (SentryTransport.TAG_KEYS.has(key) && (typeof value === 'string' || typeof value === 'number')) { + if ( + SentryTransport.TAG_KEYS.has(key) && + (typeof value === 'string' || typeof value === 'number') + ) { tags[key] = String(value); } else if (key !== 'originalLevel' && key !== 'splat') { extra[key] = value; diff --git a/src/transports/websocket.js b/src/transports/websocket.js index 2537f551..cec63cbc 100644 --- a/src/transports/websocket.js +++ b/src/transports/websocket.js @@ -5,8 +5,8 @@ * WebSocket clients in real-time. Zero overhead when no clients are connected. */ -import WebSocket from 'ws'; import Transport from 'winston-transport'; +import WebSocket from 'ws'; /** * Log level severity ordering (lower = more severe). diff --git a/src/utils/modExempt.js b/src/utils/modExempt.js new file mode 100644 index 00000000..ddfc382c --- /dev/null +++ b/src/utils/modExempt.js @@ -0,0 +1,43 @@ +/** + * Shared mod/admin exemption check. + * Used by rate limiting and link filter modules to avoid duplicating + * the same isExempt logic in both places. + */ + +import { PermissionFlagsBits } from 'discord.js'; + +/** + * Check whether a message author has mod/admin permissions and should be + * exempted from automated moderation actions. + * + * Exempt if the member: + * - has the ADMINISTRATOR Discord permission, OR + * - holds the role at `config.permissions.adminRoleId` (singular ID), OR + * - holds the role at `config.permissions.moderatorRoleId` (singular ID), OR + * - holds any role ID or name listed in `config.permissions.modRoles` (array) + * + * @param {import('discord.js').Message} message + * @param {Object} config - Merged guild config + * @returns {boolean} + */ +export function isExempt(message, config) { + const member = message.member; + if (!member) return false; + + // ADMINISTRATOR permission bypasses everything + if (member.permissions.has(PermissionFlagsBits.Administrator)) return true; + + // Singular role IDs — the actual config schema (permissions.adminRoleId / moderatorRoleId) + const adminRoleId = config.permissions?.adminRoleId; + const moderatorRoleId = config.permissions?.moderatorRoleId; + if (adminRoleId && member.roles.cache.has(adminRoleId)) return true; + if (moderatorRoleId && member.roles.cache.has(moderatorRoleId)) return true; + + // Legacy / test-facing array of role IDs or names (permissions.modRoles) + const modRoles = config.permissions?.modRoles ?? []; + if (modRoles.length === 0) return false; + + return member.roles.cache.some( + (role) => modRoles.includes(role.id) || modRoles.includes(role.name), + ); +} diff --git a/tests/api/routes/config.test.js b/tests/api/routes/config.test.js index b09f09d2..d68ba1bf 100644 --- a/tests/api/routes/config.test.js +++ b/tests/api/routes/config.test.js @@ -650,5 +650,3 @@ describe('validateSingleValue', () => { expect(errors[0]).toContain('must not be null'); }); }); - - diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index ed31e97e..3bae071e 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -314,9 +314,7 @@ describe('guilds routes', () => { describe('GET /:id/roles', () => { it('should return guild roles', async () => { - const res = await request(app) - .get('/api/v1/guilds/guild1/roles') - .set('x-api-secret', SECRET); + const res = await request(app).get('/api/v1/guilds/guild1/roles').set('x-api-secret', SECRET); expect(res.status).toBe(200); expect(res.body).toBeInstanceOf(Array); diff --git a/tests/api/utils/configAllowlist.test.js b/tests/api/utils/configAllowlist.test.js index 9d46d0be..51fc2be1 100644 --- a/tests/api/utils/configAllowlist.test.js +++ b/tests/api/utils/configAllowlist.test.js @@ -211,9 +211,7 @@ describe('configAllowlist', () => { }); it('should not strip mask sentinel from non-sensitive fields', () => { - const writes = [ - { path: 'ai.model', value: '••••••••' }, - ]; + const writes = [{ path: 'ai.model', value: '••••••••' }]; const result = stripMaskedWrites(writes); @@ -231,4 +229,4 @@ describe('configAllowlist', () => { expect(result).toEqual([]); }); }); -}); \ No newline at end of file +}); diff --git a/tests/api/utils/validateConfigPatch.test.js b/tests/api/utils/validateConfigPatch.test.js index a4b5f4c3..607e2195 100644 --- a/tests/api/utils/validateConfigPatch.test.js +++ b/tests/api/utils/validateConfigPatch.test.js @@ -121,7 +121,7 @@ describe('validateConfigPatch', () => { }); it('should reject paths exceeding 200 characters', () => { - const longPath = 'ai.' + 'a'.repeat(200); + const longPath = `ai.${'a'.repeat(200)}`; const body = { path: longPath, value: true, @@ -277,4 +277,4 @@ describe('validateConfigPatch', () => { expect(result.status).toBe(400); }); }); -}); \ No newline at end of file +}); diff --git a/tests/api/ws/logStream.test.js b/tests/api/ws/logStream.test.js index ce9460a9..311d0f8c 100644 --- a/tests/api/ws/logStream.test.js +++ b/tests/api/ws/logStream.test.js @@ -2,8 +2,12 @@ import { createHmac, randomBytes } from 'node:crypto'; import http from 'node:http'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WebSocket from 'ws'; +import { + getAuthenticatedClientCount, + setupLogStream, + stopLogStream, +} from '../../../src/api/ws/logStream.js'; import { WebSocketTransport } from '../../../src/transports/websocket.js'; -import { setupLogStream, stopLogStream, getAuthenticatedClientCount } from '../../../src/api/ws/logStream.js'; const TEST_SECRET = 'test-api-secret-for-ws'; @@ -199,7 +203,12 @@ describe('WebSocket Log Stream', () => { await authenticate(ws, mq); transport.log( - { level: 'info', message: 'real-time log', timestamp: '2026-01-01T00:00:00Z', module: 'test' }, + { + level: 'info', + message: 'real-time log', + timestamp: '2026-01-01T00:00:00Z', + module: 'test', + }, vi.fn(), ); @@ -230,7 +239,10 @@ describe('WebSocket Log Stream', () => { expect(filterOk.type).toBe('filter_ok'); expect(filterOk.filter.level).toBe('error'); - transport.log({ level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); const logMsg = await mq.next(); expect(logMsg.level).toBe('error'); expect(logMsg.message).toBe('error log'); @@ -244,8 +256,14 @@ describe('WebSocket Log Stream', () => { await mq.next(); // filter_ok // Info log should be filtered; send error right after to prove it works - transport.log({ level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); - transport.log({ level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); + transport.log( + { level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); const logMsg = await mq.next(); expect(logMsg.message).toBe('arrives'); diff --git a/tests/modules/linkFilter.test.js b/tests/modules/linkFilter.test.js new file mode 100644 index 00000000..300adbbc --- /dev/null +++ b/tests/modules/linkFilter.test.js @@ -0,0 +1,353 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { checkLinks, extractUrls, matchPhishingPattern } from '../../src/modules/linkFilter.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMessage({ + content = '', + userId = 'user1', + channelId = 'chan1', + isAdmin = false, + roleIds = [], + roleNames = [], + alertChannelSend = null, +} = {}) { + const roles = [ + ...roleIds.map((id) => ({ id, name: `role-${id}` })), + ...roleNames.map((name) => ({ id: `id-${name}`, name })), + ]; + + const mockSend = alertChannelSend ?? vi.fn(); + + const member = { + permissions: { + has: vi.fn().mockReturnValue(isAdmin), + }, + roles: { + cache: { + some: vi.fn((fn) => roles.some(fn)), + }, + }, + }; + + return { + content, + author: { id: userId, tag: `User#${userId}` }, + channel: { id: channelId }, + guild: { id: 'guild1' }, + member, + client: { + channels: { + fetch: vi.fn().mockResolvedValue({ send: mockSend }), + }, + }, + delete: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeConfig({ + enabled = true, + blockedDomains = [], + alertChannelId = 'alert-chan', + modRoles = [], +} = {}) { + return { + moderation: { + enabled: true, + alertChannelId, + linkFilter: { + enabled, + blockedDomains, + }, + }, + permissions: { modRoles }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// extractUrls +// --------------------------------------------------------------------------- + +describe('extractUrls', () => { + it('extracts hostname from http URL', () => { + const results = extractUrls('check out https://example.com/path'); + expect(results).toContainEqual(expect.objectContaining({ hostname: 'example.com' })); + }); + + it('extracts hostname from https URL', () => { + const results = extractUrls('visit https://evil.xyz/free-nitro'); + expect(results.some((r) => r.hostname === 'evil.xyz')).toBe(true); + }); + + it('strips www prefix', () => { + const results = extractUrls('go to https://www.example.com'); + expect(results.some((r) => r.hostname === 'example.com')).toBe(true); + }); + + it('extracts multiple URLs from one message', () => { + const results = extractUrls('see https://foo.com and https://bar.org'); + const hostnames = results.map((r) => r.hostname); + expect(hostnames).toContain('foo.com'); + expect(hostnames).toContain('bar.org'); + }); + + it('deduplicates repeated URLs', () => { + const results = extractUrls('https://evil.com https://evil.com'); + expect(results.filter((r) => r.hostname === 'evil.com')).toHaveLength(1); + }); + + it('returns empty array for no URLs', () => { + expect(extractUrls('hello world no links here')).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(extractUrls('')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// matchPhishingPattern +// --------------------------------------------------------------------------- + +describe('matchPhishingPattern', () => { + it('detects discord-nitro.xyz domain', () => { + expect(matchPhishingPattern('claim at https://discord-nitro.xyz')).toBeTruthy(); + }); + + it('detects free-nitro.xyz domain', () => { + expect(matchPhishingPattern('https://free-nitro.xyz/claim')).toBeTruthy(); + }); + + it('detects .xyz domain with nitro in path', () => { + expect(matchPhishingPattern('https://random.xyz/nitro-free')).toBeTruthy(); + }); + + it('detects .xyz domain with discord in URL', () => { + expect(matchPhishingPattern('https://discord.xyz/claim')).toBeTruthy(); + }); + + it('detects discord-nitro subdomain regardless of TLD', () => { + expect(matchPhishingPattern('https://discord-nitro.com/free')).toBeTruthy(); + }); + + it('detects discordnitro subdomain', () => { + expect(matchPhishingPattern('https://discordnitro.tk/verify')).toBeTruthy(); + }); + + it('detects steamgift subdomain', () => { + expect(matchPhishingPattern('https://steamgift.com/win')).toBeTruthy(); + }); + + it('does NOT flag legitimate .xyz domains', () => { + // An xyz domain with none of the scam keywords + expect(matchPhishingPattern('https://portfolio.xyz/about')).toBeNull(); + }); + + it('does NOT flag normal Discord URLs', () => { + expect(matchPhishingPattern('https://discord.com/channels/123/456')).toBeNull(); + }); + + it('returns null for clean messages', () => { + expect(matchPhishingPattern('hello world')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// checkLinks — disabled +// --------------------------------------------------------------------------- + +describe('checkLinks — disabled', () => { + it('returns { blocked: false } when linkFilter.enabled is false', async () => { + const config = makeConfig({ enabled: false, blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: 'check evil.com' }); + + const result = await checkLinks(msg, config); + expect(result).toEqual({ blocked: false }); + expect(msg.delete).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// checkLinks — blocklist +// --------------------------------------------------------------------------- + +describe('checkLinks — blocklist matching', () => { + it('blocks a message containing a blocklisted domain', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: 'check out https://evil.com/free-stuff' }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); + expect(result.domain).toBe('evil.com'); + expect(msg.delete).toHaveBeenCalledTimes(1); + }); + + it('blocks subdomains of blocklisted domains', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: 'https://sub.evil.com/path' }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); + }); + + it('does NOT block legitimate domains', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: 'visit https://legitimate.org for help' }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(false); + expect(msg.delete).not.toHaveBeenCalled(); + }); + + it('does NOT block when blockedDomains list is empty', async () => { + const config = makeConfig({ blockedDomains: [] }); + const _msg = makeMessage({ content: 'https://anything.xyz/free-bitcoin' }); + + // phishing pattern will catch this one — let's use a clean domain + const msg2 = makeMessage({ content: 'https://normalsite.org/page' }); + const result = await checkLinks(msg2, config); + expect(result.blocked).toBe(false); + }); + + it('alerts the mod channel with an embed on block', async () => { + const mockSend = vi.fn(); + const config = makeConfig({ blockedDomains: ['bad.io'], alertChannelId: 'alert-chan' }); + const msg = makeMessage({ content: 'see https://bad.io/go', alertChannelSend: mockSend }); + + await checkLinks(msg, config); + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ embeds: expect.any(Array) })); + }); + + it('does not crash if alert channel fetch fails', async () => { + const config = makeConfig({ blockedDomains: ['bad.io'], alertChannelId: 'missing-chan' }); + const msg = makeMessage({ content: 'https://bad.io' }); + msg.client.channels.fetch = vi.fn().mockRejectedValue(new Error('not found')); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); // still blocked, just no alert + }); + + it('does not crash if message delete fails', async () => { + const config = makeConfig({ blockedDomains: ['bad.io'] }); + const msg = makeMessage({ content: 'https://bad.io' }); + msg.delete = vi.fn().mockRejectedValue(new Error('permissions')); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); + }); + + it('blocks when blockedDomains entry is mixed-case or has www. prefix', async () => { + // Config entries like "Evil.Com" or "www.Evil.Com" should still match + const config = makeConfig({ blockedDomains: ['Evil.Com', 'www.BAD.IO'] }); + const msgEvil = makeMessage({ content: 'visit https://evil.com/page' }); + const msgBad = makeMessage({ content: 'https://bad.io/link' }); + + const resultEvil = await checkLinks(msgEvil, config); + expect(resultEvil.blocked).toBe(true); + + const resultBad = await checkLinks(msgBad, config); + expect(resultBad.blocked).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// checkLinks — phishing patterns +// --------------------------------------------------------------------------- + +describe('checkLinks — phishing patterns', () => { + it('blocks discord-nitro.xyz phishing link even without blocklist entry', async () => { + const config = makeConfig({ blockedDomains: [] }); + const msg = makeMessage({ content: 'get free nitro at https://discord-nitro.xyz/claim' }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); + expect(msg.delete).toHaveBeenCalledTimes(1); + }); + + it('blocks free-nitro.xyz pattern', async () => { + const config = makeConfig({ blockedDomains: [] }); + const msg = makeMessage({ content: 'https://free-nitro.xyz click here' }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// checkLinks — exemptions +// --------------------------------------------------------------------------- + +describe('checkLinks — exemptions', () => { + it('exempts administrators', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: 'https://evil.com', isAdmin: true }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(false); + expect(msg.delete).not.toHaveBeenCalled(); + }); + + it('exempts users with mod role by ID', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'], modRoles: ['mod-id'] }); + const msg = makeMessage({ content: 'https://evil.com', roleIds: ['mod-id'] }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(false); + }); + + it('exempts users with mod role by name', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'], modRoles: ['Moderator'] }); + const msg = makeMessage({ content: 'https://evil.com', roleNames: ['Moderator'] }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(false); + }); + + it('does NOT exempt regular users', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'], modRoles: ['mod-id'] }); + const msg = makeMessage({ content: 'https://evil.com', roleIds: ['user-id'] }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(true); + }); + + it('exempts admins even from phishing patterns', async () => { + const config = makeConfig({ blockedDomains: [] }); + const msg = makeMessage({ + content: 'https://discord-nitro.xyz/claim', + isAdmin: true, + }); + + const result = await checkLinks(msg, config); + expect(result.blocked).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// checkLinks — edge cases +// --------------------------------------------------------------------------- + +describe('checkLinks — edge cases', () => { + it('returns { blocked: false } for empty message content', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: '' }); + + const result = await checkLinks(msg, config); + expect(result).toEqual({ blocked: false }); + }); + + it('returns { blocked: false } for message with no URLs', async () => { + const config = makeConfig({ blockedDomains: ['evil.com'] }); + const msg = makeMessage({ content: 'just a normal message, nothing to see here' }); + + const result = await checkLinks(msg, config); + expect(result).toEqual({ blocked: false }); + }); +}); diff --git a/tests/modules/rateLimit.test.js b/tests/modules/rateLimit.test.js new file mode 100644 index 00000000..25d8c8bb --- /dev/null +++ b/tests/modules/rateLimit.test.js @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + checkRateLimit, + clearRateLimitState, + getTrackedCount, + setMaxTrackedUsers, +} from '../../src/modules/rateLimit.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a minimal fake Discord Message object. + * @param {Object} opts + */ +function makeMessage({ + userId = 'user1', + channelId = 'chan1', + guildId = 'guild1', + isAdmin = false, + roleIds = [], + roleNames = [], +} = {}) { + const roles = [ + ...roleIds.map((id) => ({ id, name: `role-${id}` })), + ...roleNames.map((name) => ({ id: `id-${name}`, name })), + ]; + + const member = { + permissions: { + has: vi.fn().mockReturnValue(isAdmin), + }, + roles: { + cache: { + some: vi.fn((fn) => roles.some(fn)), + }, + }, + }; + + const message = { + author: { id: userId, tag: `User#${userId}` }, + channel: { id: channelId }, + guild: { id: guildId }, + member, + client: { + channels: { fetch: vi.fn().mockResolvedValue({ send: vi.fn() }) }, + }, + delete: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue({ + delete: vi.fn().mockResolvedValue(undefined), + }), + url: 'https://discord.com/channels/guild1/chan1/msg1', + }; + + return message; +} + +function makeConfig({ + enabled = true, + maxMessages = 5, + windowSeconds = 10, + muteAfterTriggers = 3, + muteWindowSeconds = 300, + muteDurationSeconds = 60, + alertChannelId = null, + modRoles = [], +} = {}) { + return { + moderation: { + enabled: true, + alertChannelId, + rateLimit: { + enabled, + maxMessages, + windowSeconds, + muteAfterTriggers, + muteWindowSeconds, + muteDurationSeconds, + }, + }, + permissions: { + modRoles, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + clearRateLimitState(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('checkRateLimit — disabled', () => { + it('returns { limited: false } when rateLimit.enabled is false', async () => { + const config = makeConfig({ enabled: false }); + const msg = makeMessage(); + + for (let i = 0; i < 20; i++) { + const result = await checkRateLimit(msg, config); + expect(result).toEqual({ limited: false }); + } + expect(msg.delete).not.toHaveBeenCalled(); + }); +}); + +describe('checkRateLimit — sliding window', () => { + it('allows messages within the limit', async () => { + const config = makeConfig({ maxMessages: 5, windowSeconds: 10 }); + const msg = makeMessage(); + + for (let i = 0; i < 5; i++) { + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(false); + } + expect(msg.delete).not.toHaveBeenCalled(); + }); + + it('rate-limits the 6th message within the window', async () => { + const config = makeConfig({ maxMessages: 5, windowSeconds: 10 }); + const msg = makeMessage(); + + for (let i = 0; i < 5; i++) { + await checkRateLimit(msg, config); + } + + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(true); + expect(result.reason).toMatch(/exceeded/i); + expect(msg.delete).toHaveBeenCalledTimes(1); + }); + + it('resets after the window expires', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10 }); + const msg = makeMessage(); + + // Hit the limit + for (let i = 0; i < 4; i++) { + await checkRateLimit(msg, config); + } + expect(msg.delete).toHaveBeenCalledTimes(1); + + // Advance time past the window + vi.advanceTimersByTime(11_000); + + // Should be allowed again + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(false); + }); + + it('tracks different users independently', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10 }); + const msgA = makeMessage({ userId: 'userA' }); + const msgB = makeMessage({ userId: 'userB' }); + + for (let i = 0; i < 3; i++) { + await checkRateLimit(msgA, config); + await checkRateLimit(msgB, config); + } + + // 4th message for A → limited + const resultA = await checkRateLimit(msgA, config); + expect(resultA.limited).toBe(true); + + // 4th message for B → also limited, independently + const resultB = await checkRateLimit(msgB, config); + expect(resultB.limited).toBe(true); + }); + + it('tracks different channels independently for the same user', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10 }); + const msgChan1 = makeMessage({ userId: 'user1', channelId: 'chan1' }); + const msgChan2 = makeMessage({ userId: 'user1', channelId: 'chan2' }); + + for (let i = 0; i < 3; i++) { + await checkRateLimit(msgChan1, config); + } + + // chan2 should still have clean slate + const resultChan2 = await checkRateLimit(msgChan2, config); + expect(resultChan2.limited).toBe(false); + }); +}); + +describe('checkRateLimit — exemptions', () => { + it('exempts administrators', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10 }); + const msg = makeMessage({ isAdmin: true }); + + for (let i = 0; i < 20; i++) { + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(false); + } + expect(msg.delete).not.toHaveBeenCalled(); + }); + + it('exempts users with mod role (by role ID)', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10, modRoles: ['mod-role-id'] }); + const msg = makeMessage({ roleIds: ['mod-role-id'] }); + + for (let i = 0; i < 10; i++) { + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(false); + } + }); + + it('exempts users with mod role (by role name)', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10, modRoles: ['Moderator'] }); + const msg = makeMessage({ roleNames: ['Moderator'] }); + + for (let i = 0; i < 10; i++) { + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(false); + } + }); + + it('does NOT exempt users without mod roles', async () => { + const config = makeConfig({ maxMessages: 3, windowSeconds: 10, modRoles: ['mod-role-id'] }); + const msg = makeMessage({ roleIds: ['some-other-role'] }); + + for (let i = 0; i < 3; i++) { + await checkRateLimit(msg, config); + } + + const result = await checkRateLimit(msg, config); + expect(result.limited).toBe(true); + }); +}); + +describe('checkRateLimit — repeat offender mute', () => { + it('temp-mutes on repeated triggers within the mute window', async () => { + const config = makeConfig({ + maxMessages: 2, + windowSeconds: 10, + muteAfterTriggers: 3, + muteWindowSeconds: 300, + muteDurationSeconds: 60, + }); + + const guild = { + id: 'guild1', + members: { me: { permissions: { has: vi.fn().mockReturnValue(true) } } }, + }; + + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { some: vi.fn().mockReturnValue(false) } }, + timeout: vi.fn().mockResolvedValue(undefined), + guild, + }; + + const msg = { + author: { id: 'bad-user', tag: 'BadUser#0001' }, + channel: { id: 'chan1' }, + guild, + member, + client: { + channels: { fetch: vi.fn().mockResolvedValue({ send: vi.fn() }) }, + }, + delete: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue({ delete: vi.fn().mockResolvedValue(undefined) }), + url: 'https://discord.com/x', + }; + + // Trigger 1: 3 messages (2 ok + 1 triggers) + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); // trigger 1 + + // Trigger 2 + vi.advanceTimersByTime(11_000); // slide window to reset message count + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); // trigger 2 + + // Trigger 3 → should timeout + vi.advanceTimersByTime(11_000); + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); + const result = await checkRateLimit(msg, config); // trigger 3 + + expect(result.limited).toBe(true); + expect(result.reason).toMatch(/temp-muted/i); + expect(member.timeout).toHaveBeenCalledWith(60_000, expect.any(String)); + }); +}); + +describe('checkRateLimit — memory cap', () => { + it('evicts old entries when cap is reached', async () => { + const cap = 10; + setMaxTrackedUsers(cap); + + const config = makeConfig({ maxMessages: 100, windowSeconds: 60 }); + + // Fill exactly to the cap + for (let i = 0; i < cap; i++) { + const msg = makeMessage({ userId: `cap-user-${i}` }); + await checkRateLimit(msg, config); + } + + expect(getTrackedCount()).toBe(cap); + + // Add several more users beyond the cap. + // Each breach triggers eviction of 10% (1 entry at cap=10), then adds + // the new user — so size stays AT cap after each overflow, proving the + // eviction logic fired and the map never grows past the limit. + for (let i = 0; i < 5; i++) { + const overflow = makeMessage({ userId: `overflow-user-${i}` }); + await checkRateLimit(overflow, config); + // Size must never exceed the cap — eviction keeps it bounded. + expect(getTrackedCount()).toBeLessThanOrEqual(cap); + } + + // Sanity: the map is still actively tracking entries + expect(getTrackedCount()).toBeGreaterThan(0); + }); +}); + +describe('checkRateLimit — warns user', () => { + it('sends a reply warning on first rate-limit trigger', async () => { + const config = makeConfig({ maxMessages: 2, windowSeconds: 10 }); + const msg = makeMessage(); + + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); + await checkRateLimit(msg, config); // trigger + + // safeReply passes an options object to message.reply (with allowedMentions etc.) + expect(msg.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('too fast') }), + ); + }); +}); diff --git a/tests/modules/triage-prompt.test.js b/tests/modules/triage-prompt.test.js index e68bd565..e667bdb0 100644 --- a/tests/modules/triage-prompt.test.js +++ b/tests/modules/triage-prompt.test.js @@ -137,7 +137,7 @@ describe('triage-prompt', () => { const result = buildConversationText(context, buffer); - expect(result).toContain('(replying to Alice: "' + 'a'.repeat(100) + '")'); + expect(result).toContain(`(replying to Alice: "${'a'.repeat(100)}")`); expect(result).not.toContain('a'.repeat(150)); }); @@ -371,4 +371,4 @@ describe('triage-prompt', () => { expect(result).toContain('Targets: ["msg1","msg2"]'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/modules/triage-respond.test.js b/tests/modules/triage-respond.test.js index db9599b6..6bb5d283 100644 --- a/tests/modules/triage-respond.test.js +++ b/tests/modules/triage-respond.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildStatsAndLog, fetchChannelContext, @@ -14,7 +14,7 @@ vi.mock('../../src/logger.js', () => ({ })); vi.mock('../../src/utils/safeSend.js', () => ({ - safeSend: vi.fn(async (ch, opts) => ({ id: 'sent123', content: opts.content || opts })), + safeSend: vi.fn(async (_ch, opts) => ({ id: 'sent123', content: opts.content || opts })), })); vi.mock('../../src/utils/splitMessage.js', () => ({ @@ -23,7 +23,7 @@ vi.mock('../../src/utils/splitMessage.js', () => ({ vi.mock('../../src/utils/debugFooter.js', () => ({ buildDebugEmbed: vi.fn(() => ({ title: 'Debug' })), - extractStats: vi.fn((msg, model) => ({ + extractStats: vi.fn((_msg, model) => ({ model, promptTokens: 100, completionTokens: 50, @@ -557,9 +557,18 @@ describe('triage-respond', () => { }, }; - const result = await buildStatsAndLog({}, {}, {}, snapshot, classification, 0, mockClient, 'channel1'); + const result = await buildStatsAndLog( + {}, + {}, + {}, + snapshot, + classification, + 0, + mockClient, + 'channel1', + ); expect(result.stats.userId).toBe(null); }); }); -}); \ No newline at end of file +}); diff --git a/tests/sentry.test.js b/tests/sentry.test.js index 569e9cf5..8ee74f71 100644 --- a/tests/sentry.test.js +++ b/tests/sentry.test.js @@ -2,7 +2,7 @@ * Tests for Sentry integration module */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('sentry module', () => { beforeEach(() => { diff --git a/tests/transports/websocket.test.js b/tests/transports/websocket.test.js index 422bd4cc..fb3daddf 100644 --- a/tests/transports/websocket.test.js +++ b/tests/transports/websocket.test.js @@ -61,7 +61,10 @@ describe('WebSocketTransport', () => { transport.addClient(ws); const callback = vi.fn(); - transport.log({ level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, callback); + transport.log( + { level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, + callback, + ); expect(ws.send).toHaveBeenCalledOnce(); const sent = JSON.parse(ws.send.mock.calls[0][0]); @@ -96,7 +99,9 @@ describe('WebSocketTransport', () => { it('should handle send errors gracefully', () => { const ws = createMockWs(); - ws.send.mockImplementation(() => { throw new Error('send failed'); }); + ws.send.mockImplementation(() => { + throw new Error('send failed'); + }); transport.addClient(ws); const callback = vi.fn(); @@ -108,13 +113,16 @@ describe('WebSocketTransport', () => { const ws = createMockWs(); transport.addClient(ws); - transport.log({ - level: 'info', - message: 'test', - timestamp: '2026-01-01T00:00:00Z', - module: 'api', - userId: '123', - }, vi.fn()); + transport.log( + { + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + module: 'api', + userId: '123', + }, + vi.fn(), + ); const sent = JSON.parse(ws.send.mock.calls[0][0]); expect(sent.metadata.module).toBe('api'); @@ -129,12 +137,15 @@ describe('WebSocketTransport', () => { const circular = {}; circular.self = circular; - transport.log({ - level: 'info', - message: 'test', - timestamp: '2026-01-01T00:00:00Z', - data: circular, - }, vi.fn()); + transport.log( + { + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + data: circular, + }, + vi.fn(), + ); // Should still send — falls back to empty metadata expect(ws.send).toHaveBeenCalledOnce(); @@ -161,14 +172,20 @@ describe('WebSocketTransport', () => { it('should filter by module', () => { const filter = { module: 'api' }; - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(true); - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter), + ).toBe(true); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter), + ).toBe(false); }); it('should filter by search (case-insensitive)', () => { const filter = { search: 'ERROR' }; - expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe( + true, + ); expect(transport.passesFilter({ level: 'info', message: 'All good' }, filter)).toBe(false); }); @@ -176,11 +193,17 @@ describe('WebSocketTransport', () => { const filter = { level: 'warn', module: 'api' }; // Passes both - expect(transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter)).toBe(true); + expect( + transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter), + ).toBe(true); // Fails level - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter), + ).toBe(false); // Fails module - expect(transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter), + ).toBe(false); }); it('should apply per-client filters during broadcast', () => { @@ -194,13 +217,19 @@ describe('WebSocketTransport', () => { transport.addClient(wsErrorOnly); // Send an info-level log - transport.log({ level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); expect(wsAll.send).toHaveBeenCalledOnce(); expect(wsErrorOnly.send).not.toHaveBeenCalled(); // Send an error-level log - transport.log({ level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); expect(wsAll.send).toHaveBeenCalledTimes(2); expect(wsErrorOnly.send).toHaveBeenCalledOnce();