diff --git a/.claude/hooks/test-reminder.sh b/.claude/hooks/test-reminder.sh index 67e1d17..3983729 100755 --- a/.claude/hooks/test-reminder.sh +++ b/.claude/hooks/test-reminder.sh @@ -13,6 +13,10 @@ if ! echo "$LAST_MESSAGE" | grep -qiE '(created|wrote|added|implemented|built|up exit 0 fi +# git returns repo-root-relative paths, but this hook may run from any CWD, so +# resolve file existence against the repo root rather than the current dir. +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") + # Find source files modified in the working tree (staged + unstaged) CHANGED_SRC_FILES=$(git diff --name-only HEAD 2>/dev/null || git diff --name-only --cached 2>/dev/null || echo "") @@ -56,7 +60,17 @@ while IFS= read -r file; do continue fi - if [ -n "$TEST_FILE" ] && [ ! -f "$TEST_FILE" ]; then + if [ -n "$TEST_FILE" ] && [ ! -f "$REPO_ROOT/$TEST_FILE" ]; then + # This repo consolidates a feature's tests into .test.ts within the + # mirrored test directory (e.g. features/guilds/routes.ts is covered by + # tests/server/features/guilds/guilds.test.ts). Accept that convention — + # a .test.ts alongside the expected path — but nothing broader, + # so genuinely untested files elsewhere are still flagged. + TEST_DIR=$(dirname "$TEST_FILE") + PARENT_DIR=$(basename "$(dirname "$file")") + if [ -f "$REPO_ROOT/$TEST_DIR/$PARENT_DIR.test.ts" ]; then + continue + fi MISSING_TESTS="${MISSING_TESTS}\n - ${file} → missing ${TEST_FILE}" fi done <<< "$ALL_FILES" diff --git a/.dockerignore b/.dockerignore index eaab46a..feb2116 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,8 @@ node_modules dist .env -.env.dev -.env.prod +.env.* +!.env.example .git .gitignore *.md diff --git a/.env.example b/.env.example index cdf285d..58dbb4d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,16 @@ LOG_LEVEL=info # Database (PostgreSQL) - points to Docker container hostname DATABASE_URL=postgresql://fluxcore:fluxcore@postgres:5432/fluxcore +# Optional dev overrides for the local postgres container — defaults shown +# POSTGRES_USER=fluxcore +# POSTGRES_PASSWORD=fluxcore +# POSTGRES_DB=fluxcore + +# === Vite dev server (dashboard) === +# Defaults to 127.0.0.1 (loopback only). Set to 0.0.0.0 ONLY when running +# inside Docker — exposing Vite to all interfaces on a bare-metal host is +# unsafe. The docker-compose dashboard service sets this automatically. +# VITE_HOST=0.0.0.0 # === Bot only === @@ -24,7 +34,9 @@ GUILD_ID= # Port for the bot's internal HTTP cache sync server BOT_SYNC_PORT=3001 -# Shared secret for cache sync authentication (generate a random string) +# Shared secret for cache sync authentication. +# REQUIRED in production (NODE_ENV=production); auto-generated in development. +# Must be at least 32 characters. Generate with: openssl rand -hex 32 BOT_SYNC_SECRET= # URL the dashboard uses to reach the bot's sync server (Docker service name) BOT_SYNC_URL=http://bot:3001 @@ -43,8 +55,10 @@ DASHBOARD_SESSION_SECRET= LAVALINK_HOST=lavalink # Lavalink server port LAVALINK_PORT=2333 -# Lavalink server password (must match lavalink/application.yml) -LAVALINK_PASSWORD=youshallnotpass +# Lavalink server password (REQUIRED). +# Must match the LAVALINK_SERVER_PASSWORD env var the lavalink container reads. +# Generate with: openssl rand -base64 32 +LAVALINK_PASSWORD= # === Infrastructure (production only) === @@ -57,3 +71,31 @@ DASHBOARD_DOMAIN=localhost # Backup schedule (cron expression, default: daily at 2am) BACKUP_SCHEDULE=0 2 * * * BACKUP_RETENTION_DAYS=7 + +# === Dev tools profile (pgadmin) === +# Required when running: docker compose --profile tools up pgadmin +# PGADMIN_EMAIL=admin@fluxcore.local +# PGADMIN_PASSWORD= + +# === Production secrets (Docker secrets — DO NOT commit values) === +# In production, place each secret as a file under ./secrets/: +# secrets/discord_token +# secrets/dashboard_client_secret +# secrets/dashboard_session_secret +# secrets/bot_sync_secret +# secrets/lavalink_password +# secrets/postgres_password +# Generate strong values with: openssl rand -base64 32 +# +# The Node services read secrets via *_FILE env vars (see packages/config +# resolveSecretFiles). Example overrides if running outside docker-compose: +# DISCORD_TOKEN_FILE=/run/secrets/discord_token +# DASHBOARD_CLIENT_SECRET_FILE=/run/secrets/dashboard_client_secret +# DASHBOARD_SESSION_SECRET_FILE=/run/secrets/dashboard_session_secret +# BOT_SYNC_SECRET_FILE=/run/secrets/bot_sync_secret +# LAVALINK_PASSWORD_FILE=/run/secrets/lavalink_password +# POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password +# DATABASE_PASSWORD_FILE=/run/secrets/postgres_password # alias for deployments +# +# If DATABASE_URL is unset, it is constructed at startup from POSTGRES_USER, +# POSTGRES_PASSWORD (or POSTGRES_PASSWORD_FILE), POSTGRES_HOST, POSTGRES_DB. diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..6c76f72 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,143 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + +permissions: + contents: read + security-events: write + +jobs: + pnpm-audit: + name: pnpm audit (high+) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Audit (fail on high or critical) + run: pnpm audit --audit-level=high + + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true + + trivy-bot: + name: trivy (bot image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build bot image + run: docker build --target production-bot -t fluxcore-bot:ci . + - name: Trivy scan + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: fluxcore-bot:ci + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + format: sarif + output: trivy-bot.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-bot.sarif + category: trivy-bot + + trivy-dashboard: + name: trivy (dashboard image) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build dashboard image + run: docker build --target production-dashboard -t fluxcore-dashboard:ci . + - name: Trivy scan + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: fluxcore-dashboard:ci + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + format: sarif + output: trivy-dashboard.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-dashboard.sarif + category: trivy-dashboard + + trivy-fs: + name: trivy (filesystem & config) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Trivy filesystem scan (config, IaC, secrets) + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: fs + scan-ref: . + severity: HIGH,CRITICAL + exit-code: "1" + ignore-unfixed: true + scanners: vuln,secret,config + + forbidden-strings: + name: forbidden strings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Block re-introduction of default lavalink password + run: | + if grep -RIn --exclude-dir=.git --exclude-dir=node_modules --exclude='*.md' 'youshallnotpass' .; then + echo "::error::The literal 'youshallnotpass' must never be committed." + exit 1 + fi + - name: Block public postgres exposure + run: | + if grep -RIn --include='docker-compose*.yml' -E '"(0\.0\.0\.0:)?5432:5432"' .; then + echo "::error::Postgres must only bind 127.0.0.1, never 0.0.0.0 or unbound." + exit 1 + fi + + no-env-files: + name: no env files in repo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Assert no .env files are tracked + run: | + tracked=$(git ls-files | grep -E '^\.env(\..+)?$' | grep -v '^\.env\.example$' || true) + if [ -n "$tracked" ]; then + echo "::error::These env files must not be committed:" + echo "$tracked" + exit 1 + fi + - name: Assert .dockerignore covers env files + run: | + grep -qE '^\.env\*?$|^\.env\.\*$' .dockerignore || { + echo "::error::.dockerignore must include '.env' and '.env.*'" + exit 1 + } diff --git a/.gitignore b/.gitignore index 316382c..82f514b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ node_modules/ dist/ .env -.env.dev -.env.prod +.env.* +!.env.example *.js.map *.d.ts !src/**/*.d.ts @@ -26,3 +26,6 @@ out/ apps/dashboard/tests/e2e/.auth/ # E2E HTML report apps/dashboard/tests/e2e/.report/ +# Production Docker secrets — keep the directory but never commit values +/secrets/* +!/secrets/.gitkeep diff --git a/.gitleaks.toml b/.gitleaks.toml index 6b77565..da0ad43 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,11 +1,14 @@ -title = "FluxCore Gitleaks Configuration" +title = "FluxCore gitleaks config" [extend] useDefault = true [allowlist] -description = "Allow common false positives" +description = "Test fixtures and example placeholders" paths = [ - '''\.env\.example''', + '''.*\.test\.(ts|js)$''', + '''packages/systems/tests/.*''', + '''docs/.*''', + '''\.env\.example$''', '''pnpm-lock\.yaml''', -] \ No newline at end of file +] diff --git a/Dockerfile b/Dockerfile index 7ec6b6e..f1b9a06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,14 +20,17 @@ COPY apps/bot/package.json ./apps/bot/ COPY apps/dashboard/package.json ./apps/dashboard/ COPY packages/database/prisma ./packages/database/prisma/ COPY packages/database/prisma.config.ts ./packages/database/ -RUN pnpm install --frozen-lockfile +RUN pnpm install --frozen-lockfile \ + && chown -R node:node /app FROM deps AS development -COPY . . +COPY --chown=node:node . . +USER node CMD ["pnpm", "dev"] FROM deps AS test -COPY . . +COPY --chown=node:node . . +USER node CMD ["pnpm", "test"] # ============================================ diff --git a/apps/bot/src/events/guildMemberAdd.ts b/apps/bot/src/events/guildMemberAdd.ts index b610213..88dc072 100644 --- a/apps/bot/src/events/guildMemberAdd.ts +++ b/apps/bot/src/events/guildMemberAdd.ts @@ -7,8 +7,8 @@ import { sendLogEmbed } from "@fluxcore/systems/logging/sender"; import { formatMemberJoin } from "@fluxcore/systems/logging/formatter"; import { getWelcomeConfig } from "@fluxcore/systems/welcome/config"; import { buildWelcomeEmbed } from "@fluxcore/systems/welcome/builder"; -import { generateWelcomeImage, createStorageAdapter } from "@fluxcore/systems/welcome/image"; -import { AttachmentBuilder } from "discord.js"; +import { generateWelcomeImage, createStorageAdapter, sanitizeDisplayName } from "@fluxcore/systems/welcome/image"; +import { AttachmentBuilder, DiscordAPIError } from "discord.js"; import { getAntiRaidConfig } from "@fluxcore/systems/antiraid/config"; import { recordJoin } from "@fluxcore/systems/antiraid/tracker"; import { executeRaidAction, lockdownGuild } from "@fluxcore/systems/antiraid/actions"; @@ -133,7 +133,24 @@ const event: Event<"guildMemberAdd"> = { }); if (rolesToAdd.length > 0) { await member.roles.add(rolesToAdd, "Auto-role on join").catch((err) => { - logger.error(`Failed to assign auto-roles in guild ${member.guild.id}`, err instanceof Error ? err : new Error(String(err))); + if (err instanceof DiscordAPIError) { + if (err.code === 50013) { + logger.warn( + `auto-role: missing permissions in guild ${member.guild.id} (role likely moved above bot between cache check and API call)`, + ); + return; + } + if (err.code === 10011) { + logger.warn( + `auto-role: unknown role in guild ${member.guild.id} (role was deleted between cache check and API call)`, + ); + return; + } + } + logger.error( + `Failed to assign auto-roles in guild ${member.guild.id}`, + err instanceof Error ? err : new Error(String(err)), + ); }); } } @@ -149,15 +166,18 @@ const event: Event<"guildMemberAdd"> = { if (welcomeConfig.welcomeImageEnabled) { try { const storage = createStorageAdapter(); + const safeUsername = sanitizeDisplayName(member.user.username, 32); + const safeDisplayName = sanitizeDisplayName(member.displayName, 80); + const safeGuildName = sanitizeDisplayName(member.guild.name, 80); const imageBuffer = await generateWelcomeImage({ settings: welcomeConfig.welcomeImageConfig, member: { - username: member.user.username, - displayName: member.displayName, + username: safeUsername, + displayName: safeDisplayName, avatarUrl: member.user.displayAvatarURL({ extension: "png", size: 256 }), }, guild: { - name: member.guild.name, + name: safeGuildName, iconUrl: member.guild.iconURL({ size: 256 }) ?? undefined, memberCount: member.guild.memberCount, }, diff --git a/apps/bot/src/events/messageCreate.ts b/apps/bot/src/events/messageCreate.ts index 578dc32..53ed5c0 100644 --- a/apps/bot/src/events/messageCreate.ts +++ b/apps/bot/src/events/messageCreate.ts @@ -29,16 +29,28 @@ async function handleLevelUp( .replace("{level}", String(newLevel)) .replace("{username}", message.author.displayName); + // Lock allowedMentions so a moderator-configured template cannot ping + // @everyone, @here, or arbitrary roles. Only the leveling user + // themself is allowed to be mentioned. + const allowedMentions = { parse: [], users: [message.author.id] } as const; + try { if (settings.announceChannel === "dm") { - await message.author.send(text); + await message.author.send({ content: text, allowedMentions: { parse: [] } }); } else if (settings.announceChannel) { const channel = message.guild?.channels.cache.get(settings.announceChannel); if (channel?.isTextBased()) { - await (channel as TextChannel).send(text); + await (channel as TextChannel).send({ content: text, allowedMentions }); } } else { - await (message.channel as { send: (text: string) => Promise }).send(text); + await ( + message.channel as { + send: (payload: { + content: string; + allowedMentions: typeof allowedMentions; + }) => Promise; + } + ).send({ content: text, allowedMentions }); } } catch (error) { logger.debug( diff --git a/apps/bot/src/features/automation/system/executor.ts b/apps/bot/src/features/automation/system/executor.ts index 47c3aad..6857738 100644 --- a/apps/bot/src/features/automation/system/executor.ts +++ b/apps/bot/src/features/automation/system/executor.ts @@ -12,20 +12,28 @@ import type { StepConditionConfig, } from "@fluxcore/systems/actions/types"; -// --- Per-guild rate limiting --- +// --- Per-(guild, eventType) rate limiting --- +// +// Each event type gets its own 60/min bucket so a noisy event class +// (e.g. messageCreate) cannot starve quieter ones (e.g. memberJoin). const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute -const RATE_LIMIT_MAX_EXECUTIONS = 60; // max 60 action executions per guild per minute +const RATE_LIMIT_MAX_EXECUTIONS = 60; // max 60 action executions per (guild, eventType) per minute -const guildExecutionCounts = new Map(); +const eventBuckets = new Map(); -function checkRateLimit(guildId: string): boolean { +function bucketKey(guildId: string, eventType: string): string { + return `${guildId}\u0000${eventType}`; +} + +function checkRateLimit(guildId: string, eventType: string): boolean { + const key = bucketKey(guildId, eventType); const now = Date.now(); - let entry = guildExecutionCounts.get(guildId); + let entry = eventBuckets.get(key); if (!entry || now >= entry.resetAt) { entry = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS }; - guildExecutionCounts.set(guildId, entry); + eventBuckets.set(key, entry); } if (entry.count >= RATE_LIMIT_MAX_EXECUTIONS) { @@ -173,7 +181,7 @@ async function executeSteps( switch (step.type) { case "action": { - if (!checkRateLimit(context.guildId)) { + if (!checkRateLimit(context.guildId, context.eventType)) { logger.warn(`Rate limit hit for guild ${context.guildId} during rule "${rule.name}"`); return; } @@ -271,7 +279,7 @@ export async function processEvent( // V1: linear actions if (!rule.actions?.length) continue; for (const actionConfig of rule.actions) { - if (!checkRateLimit(context.guildId)) { + if (!checkRateLimit(context.guildId, context.eventType)) { logger.warn(`Rate limit hit for guild ${context.guildId} during rule "${rule.name}"`); return; } diff --git a/apps/bot/src/features/automation/system/registry.ts b/apps/bot/src/features/automation/system/registry.ts index 526d72a..bbd6369 100644 --- a/apps/bot/src/features/automation/system/registry.ts +++ b/apps/bot/src/features/automation/system/registry.ts @@ -11,6 +11,27 @@ type ActionExecutor = ( config: ActionConfig, ) => Promise; +/** + * Validates that a moderator-supplied emoji is either a single Unicode + * emoji cluster or a Discord custom-emoji literal `<:name:id>` / + * ``. Anything else is rejected to avoid hitting the Discord + * API with garbage values. + */ +const CUSTOM_EMOJI_REGEX = /^$/; +// Matches strings whose code points all belong to the Unicode "Emoji" +// property (single emoji or ZWJ sequence). Length cap of 64 covers +// flag/family ZWJ sequences while preventing pathological inputs. +const UNICODE_EMOJI_REGEX = + /^(?:\p{Extended_Pictographic}|\p{Emoji_Component})(?:\u200D(?:\p{Extended_Pictographic}|\p{Emoji_Component}))*$/u; + +function isValidEmoji(value: string): boolean { + if (typeof value !== "string" || value.length === 0 || value.length > 64) { + return false; + } + if (CUSTOM_EMOJI_REGEX.test(value)) return true; + return UNICODE_EMOJI_REGEX.test(value); +} + const executors = new Map(); executors.set("sendMessage", async (client, ctx, config) => { @@ -168,18 +189,33 @@ executors.set("sendWebhook", async (_client, ctx, config) => { body = body.slice(0, MAX_TEMPLATE_LENGTH); } - const BLOCKED_HEADERS = new Set([ - "host", "cookie", "set-cookie", "transfer-encoding", - "connection", "proxy-authorization", "te", "trailer", - "upgrade", + // Strict allowlist: only headers that are safe for the bot to forward on + // behalf of a guild admin. Denylists are unsafe — any new sensitive + // header (Authorization, X-Api-Key, X-Forwarded-*, Cookie, etc.) would + // silently leak. Add to this set only after a security review. + const ALLOWED_HEADERS = new Set([ + "accept", + "accept-language", + "cache-control", + "user-agent", + "x-idempotency-key", + "x-request-id", ]); + const ALLOWED_PREFIX = "x-fluxcore-"; + const userHeaders = config.webhook.headers ?? {}; const headers: Record = { "Content-Type": "application/json", }; for (const [key, value] of Object.entries(userHeaders)) { - if (!BLOCKED_HEADERS.has(key.toLowerCase())) { + const lower = key.toLowerCase(); + if (lower === "content-type") continue; // we set this ourselves + if (ALLOWED_HEADERS.has(lower) || lower.startsWith(ALLOWED_PREFIX)) { headers[key] = value; + } else { + logger.warn( + `sendWebhook: dropped non-allowlisted header "${key}" for guild ${ctx.guildId ?? "unknown"}`, + ); } } @@ -235,6 +271,12 @@ executors.set("createThread", async (client, ctx, config) => { executors.set("addReaction", async (client, ctx, config) => { if (!config.emoji || !ctx.extra?.["message.id"] || !ctx.channelId) return; + if (!isValidEmoji(config.emoji)) { + logger.warn( + `addReaction skipped: invalid emoji "${config.emoji}" for guild ${ctx.guildId ?? "unknown"}`, + ); + return; + } const channel = await client.channels.fetch(ctx.channelId); if (!channel?.isTextBased() || !("messages" in channel)) return; try { diff --git a/apps/bot/src/features/general/commands/actions.ts b/apps/bot/src/features/general/commands/actions.ts index fed3f85..93fa71d 100644 --- a/apps/bot/src/features/general/commands/actions.ts +++ b/apps/bot/src/features/general/commands/actions.ts @@ -19,6 +19,7 @@ import { ACTION_TYPES, MAX_ACTIONS_PER_RULE, CONDITION_TYPES, + isValidRuleName, type ConditionType, } from "@fluxcore/systems/actions/constants"; import { @@ -472,6 +473,20 @@ async function handleCreate( guildId: string, ) { const name = interaction.options.getString("name", true); + + if (!isValidRuleName(name)) { + await interaction.reply({ + embeds: [ + errorEmbed( + "Invalid Name", + "Rule names must be 1-50 characters using only letters, digits, spaces, underscores, or hyphens.", + ), + ], + ephemeral: true, + }); + return; + } + const eventType = interaction.options.getString("event", true) as ActionEventType; const actionType = interaction.options.getString("action-type", true) as ActionType; const channel = interaction.options.getChannel("channel"); diff --git a/apps/bot/src/features/moderation/commands/warn.ts b/apps/bot/src/features/moderation/commands/warn.ts index e0d1ea9..aa4e198 100644 --- a/apps/bot/src/features/moderation/commands/warn.ts +++ b/apps/bot/src/features/moderation/commands/warn.ts @@ -10,6 +10,7 @@ import { errorEmbed, warnEmbed, checkPermissions, + checkBotPermissions, isAboveTarget, logger, } from "@fluxcore/utils"; @@ -41,6 +42,9 @@ const command: Command = { if ( !(await checkPermissions(interaction, [ PermissionFlagsBits.ManageMessages, + ])) || + !(await checkBotPermissions(interaction, [ + PermissionFlagsBits.ManageMessages, ])) ) { return; diff --git a/apps/bot/tests/config/config.test.ts b/apps/bot/tests/config/config.test.ts index f9f41c3..890f3d1 100644 --- a/apps/bot/tests/config/config.test.ts +++ b/apps/bot/tests/config/config.test.ts @@ -10,6 +10,7 @@ describe("config", () => { vi.stubEnv("DISCORD_TOKEN", "test-token"); vi.stubEnv("CLIENT_ID", "test-client-id"); vi.stubEnv("LOG_LEVEL", "debug"); + vi.stubEnv("LAVALINK_PASSWORD", "test-lavalink-pw"); const { config } = await import("@fluxcore/config"); @@ -40,6 +41,7 @@ describe("config", () => { vi.stubEnv("DISCORD_TOKEN", "test-token"); vi.stubEnv("CLIENT_ID", "test-client-id"); vi.stubEnv("LOG_LEVEL", ""); + vi.stubEnv("LAVALINK_PASSWORD", "test-lavalink-pw"); const { config } = await import("@fluxcore/config"); expect(config.logLevel).toBe("info"); @@ -49,6 +51,7 @@ describe("config", () => { vi.stubEnv("DISCORD_TOKEN", "test-token"); vi.stubEnv("CLIENT_ID", "test-client-id"); vi.stubEnv("GUILD_ID", ""); + vi.stubEnv("LAVALINK_PASSWORD", "test-lavalink-pw"); const { config } = await import("@fluxcore/config"); expect(config.guildId).toBeUndefined(); diff --git a/apps/bot/tests/events/autorole-error-handling.test.ts b/apps/bot/tests/events/autorole-error-handling.test.ts new file mode 100644 index 0000000..ab44706 --- /dev/null +++ b/apps/bot/tests/events/autorole-error-handling.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +const warn = vi.fn(); +const error = vi.fn(); +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn, error, info: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock("@fluxcore/systems/logging/config", () => ({ + getLogConfig: vi.fn().mockResolvedValue(null), +})); +vi.mock("@fluxcore/systems/logging/persistence", () => ({ createLogEntry: vi.fn() })); +vi.mock("@fluxcore/systems/logging/sender", () => ({ sendLogEmbed: vi.fn() })); +vi.mock("@fluxcore/systems/logging/formatter", () => ({ formatMemberJoin: vi.fn() })); + +vi.mock("@fluxcore/systems/antiraid/config", () => ({ + getAntiRaidConfig: vi.fn().mockResolvedValue({ enabled: false }), +})); +vi.mock("@fluxcore/systems/antiraid/tracker", () => ({ recordJoin: vi.fn() })); +vi.mock("@fluxcore/systems/antiraid/actions", () => ({ + executeRaidAction: vi.fn(), + lockdownGuild: vi.fn(), +})); +vi.mock("@fluxcore/systems/antiraid/persistence", () => ({ createRaidEvent: vi.fn() })); + +vi.mock("@fluxcore/systems/welcome/config", () => ({ + getWelcomeConfig: vi.fn().mockResolvedValue({ + autoRoleIds: ["role-1"], + welcomeEnabled: false, + welcomeChannelId: null, + welcomeMessage: "", + welcomeImageEnabled: false, + welcomeImageConfig: { sendMode: "with" }, + dmEnabled: false, + dmMessage: "", + }), +})); + +vi.mock("@fluxcore/systems/welcome/builder", () => ({ + buildWelcomeEmbed: vi.fn(), +})); +vi.mock("@fluxcore/systems/welcome/image", () => ({ + generateWelcomeImage: vi.fn(), + createStorageAdapter: vi.fn(), + sanitizeDisplayName: (s: string) => s, +})); + +import { DiscordAPIError } from "discord.js"; + +const event = (await import("../../src/events/guildMemberAdd.js")).default; + +function mkMember(rolesAdd: ReturnType) { + return { + id: "m1", + user: { + bot: false, + username: "alice", + createdTimestamp: Date.now() - 86_400_000 * 100, + displayAvatarURL: () => "", + tag: "", + }, + displayName: "Alice", + guild: { + id: "g1", + name: "G", + memberCount: 5, + iconURL: () => null, + members: { me: { roles: { highest: { position: 100 } } } }, + roles: { + cache: new Map([["role-1", { position: 5, id: "role-1" }]]), + }, + channels: { cache: { get: () => null } }, + }, + roles: { cache: new Map(), add: rolesAdd }, + send: vi.fn(), + } as never; +} + +function makeApiError(code: number, message: string): DiscordAPIError { + const err = new DiscordAPIError( + { message, code } as never, + code, + 403, + "PUT", + "url", + { files: [] } as never, + ); + return err; +} + +describe("auto-role failure handling", () => { + beforeEach(() => { + warn.mockClear(); + error.mockClear(); + }); + + it("logs WARN with 'missing permissions' on Discord code 50013", async () => { + const rolesAdd = vi.fn().mockRejectedValue(makeApiError(50013, "Missing Permissions")); + await event.execute(mkMember(rolesAdd)); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/auto-role.*missing permissions/i), + ); + expect(error).not.toHaveBeenCalled(); + }); + + it("logs WARN with 'unknown role' on Discord code 10011", async () => { + const rolesAdd = vi.fn().mockRejectedValue(makeApiError(10011, "Unknown Role")); + await event.execute(mkMember(rolesAdd)); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/auto-role.*unknown role/i), + ); + expect(error).not.toHaveBeenCalled(); + }); + + it("logs ERROR for unexpected failure (e.g. 5xx)", async () => { + const rolesAdd = vi.fn().mockRejectedValue(new Error("ECONNRESET")); + await event.execute(mkMember(rolesAdd)); + expect(error).toHaveBeenCalled(); + }); +}); diff --git a/apps/bot/tests/events/levelup-mentions.test.ts b/apps/bot/tests/events/levelup-mentions.test.ts new file mode 100644 index 0000000..ccc0f7c --- /dev/null +++ b/apps/bot/tests/events/levelup-mentions.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock("@fluxcore/systems/leveling/config", () => ({ + getLevelSettings: vi.fn().mockResolvedValue({ + enabled: true, + xpPerMessage: 10, + xpCooldownSeconds: 0, + noXpChannels: [], + noXpRoles: [], + multipliers: [], + announceEnabled: true, + announceChannel: null, + announceMessage: "@everyone {user} hit {level}!", + }), +})); +vi.mock("@fluxcore/systems/leveling/persistence", () => ({ + getUserLevel: vi.fn().mockResolvedValue(null), + addXp: vi.fn().mockResolvedValue({ leveledUp: true, newLevel: 2 }), +})); +vi.mock("@fluxcore/systems/leveling/xp", () => ({ + applyMultipliers: (xp: number) => xp, +})); +vi.mock("@fluxcore/systems/leveling/constants", () => ({ XP_RANDOMNESS: 1 })); +vi.mock("@fluxcore/systems/leveling/rewards", () => ({ + checkAndGrantRewards: vi.fn(), +})); + +const event = (await import("../../src/events/messageCreate.js")).default; + +describe("level-up announcement allowedMentions", () => { + it("passes allowedMentions to channel.send when announcing in same channel", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const message = { + guild: { id: "g1", channels: { cache: { get: () => null } } }, + author: { id: "u1", bot: false, displayName: "Alice", send: vi.fn() }, + member: { roles: { cache: new Map() } }, + channelId: "c1", + channel: { send }, + } as never; + + await event.execute(message); + + expect(send).toHaveBeenCalledTimes(1); + const arg = send.mock.calls[0][0] as { + content: string; + allowedMentions: { parse: string[]; users: string[] }; + }; + expect(typeof arg).toBe("object"); + expect(arg.content).toContain("@everyone"); + expect(arg.allowedMentions).toEqual({ parse: [], users: ["u1"] }); + }); + + it("passes allowedMentions when announceChannel is configured", async () => { + const { getLevelSettings } = await import("@fluxcore/systems/leveling/config"); + (getLevelSettings as ReturnType).mockResolvedValueOnce({ + enabled: true, + xpPerMessage: 10, + xpCooldownSeconds: 0, + noXpChannels: [], + noXpRoles: [], + multipliers: [], + announceEnabled: true, + announceChannel: "ch-announce", + announceMessage: "<@&modRole> {user} hit {level}!", + }); + + const announceSend = vi.fn().mockResolvedValue(undefined); + const message = { + guild: { + id: "g1", + channels: { + cache: { + get: (id: string) => + id === "ch-announce" + ? { isTextBased: () => true, send: announceSend } + : null, + }, + }, + }, + author: { id: "u1", bot: false, displayName: "Alice", send: vi.fn() }, + member: { roles: { cache: new Map() } }, + channelId: "c1", + channel: { send: vi.fn() }, + } as never; + + await event.execute(message); + + expect(announceSend).toHaveBeenCalledTimes(1); + const arg = announceSend.mock.calls[0][0] as { + allowedMentions: { parse: string[]; users: string[] }; + }; + expect(arg.allowedMentions).toEqual({ + parse: [], + users: ["u1"], + }); + }); +}); diff --git a/apps/bot/tests/events/messageDelete.test.ts b/apps/bot/tests/events/messageDelete.test.ts index fd68594..c1cd0fc 100644 --- a/apps/bot/tests/events/messageDelete.test.ts +++ b/apps/bot/tests/events/messageDelete.test.ts @@ -53,7 +53,8 @@ function createMockMessage({ id: "msg-1", content, member: { roles: { cache: new Map() } }, - attachments: new Map(), + // Discord.js exposes attachments as a Collection (extends Map) which has .map() + attachments: Object.assign(new Map(), { map: () => [] }), }; } diff --git a/apps/bot/tests/events/voiceStateUpdate.test.ts b/apps/bot/tests/events/voiceStateUpdate.test.ts index 6c5e944..dc6790f 100644 --- a/apps/bot/tests/events/voiceStateUpdate.test.ts +++ b/apps/bot/tests/events/voiceStateUpdate.test.ts @@ -29,7 +29,7 @@ const event = voiceModule.default; function createMockVoiceState({ channelId = null as string | null, - member = { id: "user-123" }, + member = { id: "user-123", user: { bot: false } }, guildId = "guild-789", channel = null as { members: { size: number }; id: string; name: string; delete: ReturnType } | null, } = {}) { @@ -63,7 +63,7 @@ describe("voiceStateUpdate event", () => { const oldState = createMockVoiceState({ channelId: null }); const newState = createMockVoiceState({ channelId: "hub-channel", - member: { id: "user-123" }, + member: { id: "user-123", user: { bot: false } }, }); await event.execute(oldState as never, newState as never); diff --git a/apps/bot/tests/events/welcome-sanitize.test.ts b/apps/bot/tests/events/welcome-sanitize.test.ts new file mode 100644 index 0000000..4fe35b1 --- /dev/null +++ b/apps/bot/tests/events/welcome-sanitize.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock("@fluxcore/systems/logging/config", () => ({ + getLogConfig: vi.fn().mockResolvedValue(null), +})); +vi.mock("@fluxcore/systems/logging/persistence", () => ({ + createLogEntry: vi.fn(), +})); +vi.mock("@fluxcore/systems/logging/sender", () => ({ + sendLogEmbed: vi.fn(), +})); +vi.mock("@fluxcore/systems/logging/formatter", () => ({ + formatMemberJoin: vi.fn(), +})); + +vi.mock("@fluxcore/systems/antiraid/config", () => ({ + getAntiRaidConfig: vi.fn().mockResolvedValue({ enabled: false }), +})); +vi.mock("@fluxcore/systems/antiraid/tracker", () => ({ recordJoin: vi.fn() })); +vi.mock("@fluxcore/systems/antiraid/actions", () => ({ + executeRaidAction: vi.fn(), + lockdownGuild: vi.fn(), +})); +vi.mock("@fluxcore/systems/antiraid/persistence", () => ({ + createRaidEvent: vi.fn(), +})); + +vi.mock("@fluxcore/systems/welcome/config", () => ({ + getWelcomeConfig: vi.fn().mockResolvedValue({ + autoRoleIds: [], + welcomeEnabled: true, + welcomeChannelId: "ch-welcome", + welcomeMessage: "hi", + welcomeImageEnabled: true, + welcomeImageConfig: { sendMode: "with" }, + dmEnabled: false, + dmMessage: "", + }), +})); + +vi.mock("@fluxcore/systems/welcome/builder", () => ({ + buildWelcomeEmbed: vi.fn().mockReturnValue({ + setImage: vi.fn().mockReturnThis(), + }), +})); + +const generateWelcomeImage = vi + .fn() + .mockResolvedValue(Buffer.from("image")); +vi.mock("@fluxcore/systems/welcome/image", () => ({ + generateWelcomeImage: (...args: unknown[]) => generateWelcomeImage(...args), + createStorageAdapter: vi.fn().mockReturnValue({}), + sanitizeDisplayName: (raw: string, maxLen: number): string => { + const BIDI_OVERRIDES = /[\u202A-\u202E\u2066-\u2069]/g; + const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF]/g; + const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g; + const WHITESPACE_RUN = /\s+/g; + if (typeof raw !== "string") return "Unknown"; + let out = raw + .normalize("NFC") + .replace(BIDI_OVERRIDES, "") + .replace(ZERO_WIDTH, "") + .replace(CONTROL_CHARS, "") + .replace(WHITESPACE_RUN, " ") + .trim(); + if (out.length === 0) return "Unknown"; + if (out.length > maxLen) out = out.slice(0, maxLen); + return out; + }, +})); + +const event = ( + await import("../../src/events/guildMemberAdd.js") +).default; + +describe("guildMemberAdd: sanitizes hostile names before canvas render", () => { + beforeEach(() => { + generateWelcomeImage.mockClear(); + }); + + it("strips RTL override and zero-width from username/displayName/guild.name", async () => { + const sentChannel = { + isTextBased: () => true, + send: vi.fn().mockResolvedValue(undefined), + }; + const member = { + id: "m1", + user: { + bot: false, + username: "\u202Eevil\u202Duser", + createdTimestamp: Date.now() - 10 * 86_400_000, + displayAvatarURL: () => "https://cdn/avatar.png", + tag: "tag", + }, + displayName: "a\u200B".repeat(100), + guild: { + id: "g1", + name: "Server\u202Ename", + memberCount: 5, + channels: { cache: { get: () => sentChannel } }, + iconURL: () => null, + members: { me: null }, + roles: { cache: new Map() }, + }, + roles: { cache: new Map(), add: vi.fn() }, + send: vi.fn(), + } as never; + + await event.execute(member); + + expect(generateWelcomeImage).toHaveBeenCalledTimes(1); + const call = generateWelcomeImage.mock.calls[0][0]; + expect(call.member.username).toBe("eviluser"); + expect(call.member.displayName).not.toContain("\u200B"); + expect(call.member.displayName.length).toBeLessThanOrEqual(80); + expect(call.guild.name).toBe("Servername"); + }); +}); diff --git a/apps/bot/tests/features/automation/system/executor-rate-limit.test.ts b/apps/bot/tests/features/automation/system/executor-rate-limit.test.ts new file mode 100644 index 0000000..299ca97 --- /dev/null +++ b/apps/bot/tests/features/automation/system/executor-rate-limit.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "test-token", + clientId: "test-client-id", + guildId: undefined, + logLevel: "info", + }, +})); + +vi.mock("@fluxcore/utils", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const { getRulesForEventMock, executorMock, logExecutionMock } = vi.hoisted( + () => ({ + getRulesForEventMock: vi.fn(), + executorMock: vi.fn().mockResolvedValue(undefined), + logExecutionMock: vi.fn().mockResolvedValue(undefined), + }), +); + +vi.mock("@fluxcore/systems/actions/cache", () => ({ + getRulesForEvent: (...args: unknown[]) => getRulesForEventMock(...args), +})); + +vi.mock("@fluxcore/systems/actions/config", () => ({ + getGuildSettingsOrDefault: () => ({ + globalEnabled: true, + maxRules: 25, + logChannelId: null, + }), +})); + +vi.mock("@fluxcore/systems/actions/persistence", () => ({ + logExecution: (...args: unknown[]) => logExecutionMock(...args), +})); + +vi.mock("../../../../src/features/automation/system/registry.js", () => ({ + getExecutor: () => executorMock, +})); + +import type { Client } from "discord.js"; + +const { processEvent } = await import( + "../../../../src/features/automation/system/executor.js" +); + +type TestEventContext = { + eventType: string; + guildId: string; + timestamp: string; +}; + +const fakeClient = {} as Client; + +function makeRule(eventType: string, id = 1) { + return { + id, + guildId: "g-rl", + name: `r-${eventType}-${id}`, + enabled: true, + eventType, + actions: [{ type: "addRole", roleId: "r" }], + conditions: {}, + priority: 0, + createdBy: "u", + }; +} + +function ctx(guildId: string, eventType: string): TestEventContext { + return { + eventType, + guildId, + timestamp: new Date().toISOString(), + }; +} + +describe("processEvent rate limiting", () => { + beforeEach(() => { + executorMock.mockClear(); + getRulesForEventMock.mockReset(); + logExecutionMock.mockClear(); + }); + + it("does not let one event type starve another", async () => { + const guildId = `guild-starve-${Date.now()}`; + getRulesForEventMock.mockImplementation((_g: string, ev: string) => [ + makeRule(ev), + ]); + + // Burn through the messageCreate budget + for (let i = 0; i < 80; i++) { + await processEvent(fakeClient, ctx(guildId, "messageCreated") as never); + } + + const messageCreateExecCount = executorMock.mock.calls.length; + expect(messageCreateExecCount).toBeLessThanOrEqual(60); + expect(messageCreateExecCount).toBeGreaterThan(0); + + // Now memberJoin must still be allowed: separate bucket + executorMock.mockClear(); + await processEvent(fakeClient, ctx(guildId, "memberJoin") as never); + + expect(executorMock).toHaveBeenCalledTimes(1); + }); + + it("enforces the per-(guild, eventType) cap independently per guild", async () => { + const a = `guild-a-${Date.now()}`; + const b = `guild-b-${Date.now()}`; + getRulesForEventMock.mockImplementation((_g: string, ev: string) => [ + makeRule(ev), + ]); + + for (let i = 0; i < 70; i++) { + await processEvent(fakeClient, ctx(a, "messageCreated")); + } + const aCount = executorMock.mock.calls.length; + expect(aCount).toBeLessThanOrEqual(60); + + executorMock.mockClear(); + await processEvent(fakeClient, ctx(b, "messageCreated")); + expect(executorMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/bot/tests/features/automation/system/registry-emoji.test.ts b/apps/bot/tests/features/automation/system/registry-emoji.test.ts new file mode 100644 index 0000000..ff4fe28 --- /dev/null +++ b/apps/bot/tests/features/automation/system/registry-emoji.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +const warn = vi.hoisted(() => vi.fn()); +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn, error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock("node:dns/promises", () => ({ + lookup: vi.fn().mockResolvedValue({ address: "1.1.1.1", family: 4 }), +})); + +const { getExecutor } = await import( + "../../../../src/features/automation/system/registry.js" +); + +const baseCtx = { + eventType: "messageCreated" as const, + guildId: "g1", + userId: "u1", + userName: "alice", + userTag: "alice#0001", + userMention: "<@u1>", + channelId: "c1", + guildName: "G", + memberCount: 10, + timestamp: new Date().toISOString(), + extra: { "message.id": "m1" }, +}; + +function mkClient(fetchSpy: ReturnType) { + return { + channels: { + fetch: vi.fn().mockResolvedValue({ + isTextBased: () => true, + messages: { fetch: fetchSpy }, + }), + }, + } as never; +} + +describe("addReaction emoji validation", () => { + beforeEach(() => warn.mockClear()); + + it("rejects garbage strings without fetching the message", async () => { + const fetchSpy = vi.fn(); + const client = mkClient(fetchSpy); + + const executor = getExecutor("addReaction")!; + await executor(client, baseCtx as never, { + type: "addReaction", + emoji: "not-an-emoji-at-all", + } as never); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("invalid emoji"), + ); + }); + + it("accepts a unicode emoji", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ react: vi.fn() }); + const client = mkClient(fetchSpy); + const executor = getExecutor("addReaction")!; + await executor(client, baseCtx as never, { + type: "addReaction", + emoji: "\u{1F389}", + } as never); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("accepts a Discord custom emoji <:name:id>", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ react: vi.fn() }); + const client = mkClient(fetchSpy); + const executor = getExecutor("addReaction")!; + await executor(client, baseCtx as never, { + type: "addReaction", + emoji: "<:partyparrot:123456789012345678>", + } as never); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("accepts an animated custom emoji ", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ react: vi.fn() }); + const client = mkClient(fetchSpy); + const executor = getExecutor("addReaction")!; + await executor(client, baseCtx as never, { + type: "addReaction", + emoji: "", + } as never); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("rejects bare colon shortcodes like :smile:", async () => { + const fetchSpy = vi.fn(); + const client = mkClient(fetchSpy); + const executor = getExecutor("addReaction")!; + await executor(client, baseCtx as never, { + type: "addReaction", + emoji: ":smile:", + } as never); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/bot/tests/features/automation/system/registry-webhook.test.ts b/apps/bot/tests/features/automation/system/registry-webhook.test.ts new file mode 100644 index 0000000..04bb413 --- /dev/null +++ b/apps/bot/tests/features/automation/system/registry-webhook.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock("node:dns/promises", () => ({ + lookup: vi.fn().mockResolvedValue({ address: "93.184.216.34", family: 4 }), +})); + +const { getExecutor } = await import( + "../../../../src/features/automation/system/registry.js" +); + +const baseCtx = { + eventType: "memberJoin" as const, + guildId: "g1", + userId: "u1", + userName: "alice", + userTag: "alice#0001", + userMention: "<@u1>", + channelId: "c1", + guildName: "G", + memberCount: 10, + timestamp: new Date().toISOString(), +}; + +describe("sendWebhook header allowlist", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("", { status: 200 })); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + async function run(headers: Record) { + const executor = getExecutor("sendWebhook")!; + await executor({} as never, baseCtx as never, { + type: "sendWebhook", + webhook: { + url: "https://example.com/hook", + method: "POST", + headers, + bodyTemplate: "{}", + }, + } as never); + const call = fetchSpy.mock.calls[0]; + return (call?.[1] as { headers: Record } | undefined) + ?.headers as Record; + } + + it("strips Authorization header", async () => { + const sent = await run({ Authorization: "Bearer leaked" }); + expect(sent).toBeDefined(); + expect(Object.keys(sent).map((k) => k.toLowerCase())).not.toContain( + "authorization", + ); + }); + + it("strips X-Api-Key header", async () => { + const sent = await run({ "X-Api-Key": "secret" }); + expect(Object.keys(sent).map((k) => k.toLowerCase())).not.toContain( + "x-api-key", + ); + }); + + it("strips X-Forwarded-For and X-Forwarded-Host", async () => { + const sent = await run({ + "X-Forwarded-For": "127.0.0.1", + "X-Forwarded-Host": "internal", + }); + const lower = Object.keys(sent).map((k) => k.toLowerCase()); + expect(lower).not.toContain("x-forwarded-for"); + expect(lower).not.toContain("x-forwarded-host"); + }); + + it("strips Cookie header", async () => { + const sent = await run({ Cookie: "session=abc" }); + expect(Object.keys(sent).map((k) => k.toLowerCase())).not.toContain( + "cookie", + ); + }); + + it("allows X-Idempotency-Key (allowlisted)", async () => { + const sent = await run({ "X-Idempotency-Key": "abc-123" }); + const lower = Object.keys(sent).map((k) => k.toLowerCase()); + expect(lower).toContain("x-idempotency-key"); + }); + + it("allows custom X-Fluxcore-* headers (allowlisted prefix)", async () => { + const sent = await run({ "X-Fluxcore-Source": "rule-42" }); + const lower = Object.keys(sent).map((k) => k.toLowerCase()); + expect(lower).toContain("x-fluxcore-source"); + }); + + it("always sets Content-Type: application/json", async () => { + const sent = await run({}); + expect(sent["Content-Type"] ?? sent["content-type"]).toBe( + "application/json", + ); + }); +}); diff --git a/apps/bot/tests/features/general/commands/actions-name-validation.test.ts b/apps/bot/tests/features/general/commands/actions-name-validation.test.ts new file mode 100644 index 0000000..59cc3d9 --- /dev/null +++ b/apps/bot/tests/features/general/commands/actions-name-validation.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" }, +})); + +vi.mock("@fluxcore/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkPermissions: vi.fn().mockResolvedValue(true), + }; +}); + +const mockCreateRule = vi.fn(); +const mockGetRuleByName = vi.fn().mockResolvedValue(null); +const mockCountRules = vi.fn().mockResolvedValue(0); +vi.mock("@fluxcore/systems/actions/persistence", () => ({ + createRule: (...a: unknown[]) => mockCreateRule(...a), + updateRule: vi.fn(), + deleteRule: vi.fn(), + getRuleByName: (...a: unknown[]) => mockGetRuleByName(...a), + countRules: (...a: unknown[]) => mockCountRules(...a), + getRecentLogs: vi.fn(), + notifyCacheInvalidation: vi.fn(), +})); + +vi.mock("@fluxcore/systems/actions/cache", () => ({ + addRuleToCache: vi.fn(), + removeRuleFromCache: vi.fn(), + updateRuleInCache: vi.fn(), + getRulesForGuild: vi.fn().mockReturnValue([]), +})); + +vi.mock("@fluxcore/systems/actions/config", () => ({ + getGuildSettingsOrDefault: vi.fn().mockReturnValue({ + maxRules: 25, + globalEnabled: true, + logChannelId: null, + }), + setGuildSettings: vi.fn(), +})); + +const command = ( + await import("../../../../src/features/general/commands/actions.js") +).default; + +function mkInteraction(name: string) { + const replies: unknown[] = []; + return { + options: { + getSubcommand: () => "create", + getString: (k: string, _req?: boolean) => { + if (k === "name") return name; + if (k === "event") return "memberJoin"; + if (k === "action-type") return "sendMessage"; + return null; + }, + getInteger: () => null, + getBoolean: () => null, + getChannel: () => null, + getRole: () => null, + }, + user: { id: "u1" }, + guildId: "g1", + replies, + reply: (payload: unknown) => { + replies.push(payload); + return Promise.resolve(); + }, + } as never; +} + +describe("/actions create — name validation", () => { + beforeEach(() => { + mockCreateRule.mockReset(); + mockGetRuleByName.mockResolvedValue(null); + mockCountRules.mockResolvedValue(0); + }); + + it("rejects @everyone", async () => { + const ix = mkInteraction("@everyone"); + await command.execute(ix); + expect(mockCreateRule).not.toHaveBeenCalled(); + const reply = (ix as { replies: unknown[] }).replies[0]; + expect(JSON.stringify(reply)).toMatch(/Invalid Name/); + }); + + it("rejects backticks", async () => { + const ix = mkInteraction("`evil`"); + await command.execute(ix); + expect(mockCreateRule).not.toHaveBeenCalled(); + }); + + it("rejects zero-width chars", async () => { + const ix = mkInteraction("hi\u200Bthere"); + await command.execute(ix); + expect(mockCreateRule).not.toHaveBeenCalled(); + }); + + it("accepts a normal name", async () => { + mockCreateRule.mockResolvedValue({ + id: 1, + guildId: "g1", + name: "welcome_rule", + enabled: true, + eventType: "memberJoin", + actions: [], + conditions: {}, + priority: 0, + createdBy: "u1", + }); + const ix = mkInteraction("welcome_rule"); + await command.execute(ix); + expect(mockCreateRule).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/bot/tests/features/general/commands/lockdown.test.ts b/apps/bot/tests/features/general/commands/lockdown.test.ts index 4079384..77e4f78 100644 --- a/apps/bot/tests/features/general/commands/lockdown.test.ts +++ b/apps/bot/tests/features/general/commands/lockdown.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock config vi.mock("@fluxcore/config", () => ({ @@ -71,6 +71,10 @@ function createMockInteraction({ } describe("lockdown command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("has correct command metadata", () => { expect(command.data.name).toBe("lockdown"); expect(command.category).toBe("Admin"); diff --git a/apps/bot/tests/features/moderation/commands/kick.test.ts b/apps/bot/tests/features/moderation/commands/kick.test.ts index 084c932..c847586 100644 --- a/apps/bot/tests/features/moderation/commands/kick.test.ts +++ b/apps/bot/tests/features/moderation/commands/kick.test.ts @@ -24,6 +24,18 @@ vi.mock("@fluxcore/utils", async (importOriginal) => { }; }); +// Mock moderation persistence + DM so the command doesn't hit the real PrismaClient +const mockGetModSettings = vi.fn().mockResolvedValue({ dmOnPunishment: false }); +const mockCreateModCase = vi.fn().mockResolvedValue({ id: 1, caseNumber: 1 }); +const mockDmOnPunishment = vi.fn().mockResolvedValue(undefined); +vi.mock("@fluxcore/systems/moderation/persistence", () => ({ + getModSettings: (...args: unknown[]) => mockGetModSettings(...args), + createModCase: (...args: unknown[]) => mockCreateModCase(...args), +})); +vi.mock("@fluxcore/systems/moderation/dm", () => ({ + dmOnPunishment: (...args: unknown[]) => mockDmOnPunishment(...args), +})); + const kickModule = await import("../../../../src/features/moderation/commands/kick.js"); const command = kickModule.default; diff --git a/apps/bot/tests/features/moderation/commands/timeout.test.ts b/apps/bot/tests/features/moderation/commands/timeout.test.ts index 7ee94b8..83b1b4a 100644 --- a/apps/bot/tests/features/moderation/commands/timeout.test.ts +++ b/apps/bot/tests/features/moderation/commands/timeout.test.ts @@ -24,6 +24,18 @@ vi.mock("@fluxcore/utils", async (importOriginal) => { }; }); +// Mock moderation persistence + DM so the command doesn't hit the real PrismaClient +const mockGetModSettings = vi.fn().mockResolvedValue({ dmOnPunishment: false }); +const mockCreateModCase = vi.fn().mockResolvedValue({ id: 1, caseNumber: 1 }); +const mockDmOnPunishment = vi.fn().mockResolvedValue(undefined); +vi.mock("@fluxcore/systems/moderation/persistence", () => ({ + getModSettings: (...args: unknown[]) => mockGetModSettings(...args), + createModCase: (...args: unknown[]) => mockCreateModCase(...args), +})); +vi.mock("@fluxcore/systems/moderation/dm", () => ({ + dmOnPunishment: (...args: unknown[]) => mockDmOnPunishment(...args), +})); + const timeoutModule = await import( "../../../../src/features/moderation/commands/timeout.js" ); diff --git a/apps/bot/tests/features/moderation/commands/warn.test.ts b/apps/bot/tests/features/moderation/commands/warn.test.ts index aa6ea22..3482b96 100644 --- a/apps/bot/tests/features/moderation/commands/warn.test.ts +++ b/apps/bot/tests/features/moderation/commands/warn.test.ts @@ -12,12 +12,14 @@ vi.mock("@fluxcore/config", () => ({ // Mock permissions (keep real embed/logger exports) const mockCheckPermissions = vi.fn().mockResolvedValue(true); +const mockCheckBotPermissions = vi.fn().mockResolvedValue(true); const mockIsAboveTarget = vi.fn().mockReturnValue(true); vi.mock("@fluxcore/utils", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, checkPermissions: (...args: unknown[]) => mockCheckPermissions(...args), + checkBotPermissions: (...args: unknown[]) => mockCheckBotPermissions(...args), isAboveTarget: (...args: unknown[]) => mockIsAboveTarget(...args), }; }); @@ -94,6 +96,7 @@ describe("warn command", () => { beforeEach(() => { vi.clearAllMocks(); mockCheckPermissions.mockResolvedValue(true); + mockCheckBotPermissions.mockResolvedValue(true); mockIsAboveTarget.mockReturnValue(true); mockCreateWarning.mockResolvedValue({ id: 1 }); mockGetWarningCount.mockResolvedValue(1); @@ -246,6 +249,27 @@ describe("warn command", () => { expect(target.send).not.toHaveBeenCalled(); }); + it("aborts when the bot lacks ManageMessages", async () => { + mockCheckPermissions.mockResolvedValueOnce(true); + mockCheckBotPermissions.mockResolvedValueOnce(false); + + const interaction = createMockInteraction(); + await command.execute(interaction as never); + + expect(mockCheckBotPermissions).toHaveBeenCalledTimes(1); + expect(mockCreateWarning).not.toHaveBeenCalled(); + }); + + it("calls checkBotPermissions on the happy path", async () => { + mockCheckPermissions.mockResolvedValueOnce(true); + mockCheckBotPermissions.mockResolvedValueOnce(true); + + const interaction = createMockInteraction(); + await command.execute(interaction as never); + + expect(mockCheckBotPermissions).toHaveBeenCalledTimes(1); + }); + it("handles DM failure silently", async () => { mockGetWarnSettings.mockResolvedValueOnce({ dmOnWarn: true, diff --git a/apps/bot/tests/features/tickets/commands/transcript.test.ts b/apps/bot/tests/features/tickets/commands/transcript.test.ts index 5be4a72..9af1489 100644 --- a/apps/bot/tests/features/tickets/commands/transcript.test.ts +++ b/apps/bot/tests/features/tickets/commands/transcript.test.ts @@ -37,18 +37,29 @@ function createMockInteraction({ channelId = "channel-1", guildId = "guild-1" as string | null, } = {}) { - const mockMessages = new Map(); - mockMessages.set("msg-1", { - author: { - displayName: "TestUser", - username: "testuser", - id: "user-1", - displayAvatarURL: vi.fn().mockReturnValue("https://example.com/avatar.png"), + const messageValues = [ + { + author: { + displayName: "TestUser", + username: "testuser", + id: "user-1", + displayAvatarURL: vi.fn().mockReturnValue("https://example.com/avatar.png"), + }, + content: "Hello!", + createdAt: new Date(), + // Discord.js exposes attachments as a Collection (has .map()) + attachments: Object.assign(new Map(), { map: () => [] as string[] }), }, - content: "Hello!", - createdAt: new Date(), - attachments: new Map(), - }); + ]; + + // Discord.js fetch() returns a Collection; .reverse() returns a Collection + // whose .map() iterates over the message values. + const mockCollection = { + size: messageValues.length, + reverse: vi.fn().mockReturnThis(), + map: (fn: (value: (typeof messageValues)[number]) => T): T[] => + messageValues.map(fn), + }; return { options: {}, @@ -58,10 +69,7 @@ function createMockInteraction({ channelId, channel: { messages: { - fetch: vi.fn().mockResolvedValue({ - reverse: vi.fn().mockReturnValue(mockMessages), - size: mockMessages.size, - }), + fetch: vi.fn().mockResolvedValue(mockCollection), }, }, reply: vi.fn(), diff --git a/apps/bot/tests/utils/logger.test.ts b/apps/bot/tests/utils/logger.test.ts index b7b93c8..9ad0783 100644 --- a/apps/bot/tests/utils/logger.test.ts +++ b/apps/bot/tests/utils/logger.test.ts @@ -10,6 +10,7 @@ describe("logger", () => { vi.stubEnv("DISCORD_TOKEN", "test-token"); vi.stubEnv("CLIENT_ID", "test-client-id"); vi.stubEnv("LOG_LEVEL", "debug"); + vi.stubEnv("LAVALINK_PASSWORD", "test-lavalink-pw"); const { logger } = await import("@fluxcore/utils"); expect(logger).toBeDefined(); @@ -23,6 +24,7 @@ describe("logger", () => { vi.stubEnv("DISCORD_TOKEN", "test-token"); vi.stubEnv("CLIENT_ID", "test-client-id"); vi.stubEnv("LOG_LEVEL", "error"); + vi.stubEnv("LAVALINK_PASSWORD", "test-lavalink-pw"); // Import first (dotenv may log during import) const { logger } = await import("@fluxcore/utils"); @@ -45,6 +47,7 @@ describe("logger", () => { vi.stubEnv("DISCORD_TOKEN", "test-token"); vi.stubEnv("CLIENT_ID", "test-client-id"); vi.stubEnv("LOG_LEVEL", "error"); + vi.stubEnv("LAVALINK_PASSWORD", "test-lavalink-pw"); // Import first (dotenv may log during import) const { logger } = await import("@fluxcore/utils"); diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 92a8ab5..30b2cfc 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -62,18 +62,24 @@ "react-dom": "^19.1.0", "react-i18next": "^15.5.2", "recharts": "^3.8.0", + "safe-regex": "^2.1.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", "zod": "^3.25.17" }, "devDependencies": { "@playwright/test": "^1.49.0", "@tailwindcss/vite": "^4.1.7", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", + "@types/safe-regex": "^1.1.6", "@vitejs/plugin-react": "^4.5.2", "@vitest/coverage-v8": "^4.0.18", "concurrently": "^9.1.2", + "jsdom": "^29.1.1", "tailwindcss": "^4.1.7", "tsx": "^4.21.0", "typescript": "catalog:", diff --git a/apps/dashboard/src/client/features/automation/components/ActionFields.tsx b/apps/dashboard/src/client/features/automation/components/ActionFields.tsx index 88e759c..baecac5 100644 --- a/apps/dashboard/src/client/features/automation/components/ActionFields.tsx +++ b/apps/dashboard/src/client/features/automation/components/ActionFields.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { Icon } from "../../../shared/components/Icon"; import { Label } from "../../../shared/ui/label"; import { Input } from "../../../shared/ui/input"; import { Textarea } from "../../../shared/ui/textarea"; @@ -9,14 +10,27 @@ import { SelectTrigger, SelectValue, } from "../../../shared/ui/select"; +import { VariableEditor } from "../../../shared/ui/variable-field"; +import type { VariableDescriptor } from "../../../shared/ui/variable-field"; import type { ActionFieldDescriptor, Channel, Role } from "../../../shared/lib/schemas"; +const VARIABLE_FIELD_KEYS = new Set([ + "message", + "embed.title", + "embed.description", + "embed.footer", + "webhook.bodyTemplate", + "nickname", + "threadName", +]); + interface ActionFieldsProps { fields: ActionFieldDescriptor[]; values: Record; onChange: (key: string, value: unknown) => void; channels: Channel[]; roles: Role[]; + variables: VariableDescriptor[]; } function getNestedValue(obj: Record, path: string): unknown { @@ -35,18 +49,29 @@ export function ActionFields({ onChange, channels, roles, + variables, }: ActionFieldsProps) { const { t } = useTranslation("common"); return (
{fields.map((field) => { const value = getNestedValue(values, field.key) ?? ""; + const fieldId = `af-${field.key.replace(/\./g, "-")}`; + const colorHex = + typeof value === "number" + ? `#${value.toString(16).padStart(6, "0")}` + : String(value) || "#5865f2"; return (
-